feat: integrate media upload handling for order transactions, update validation and frontend components for photo requirements

This commit is contained in:
Yoga Pangestu 2026-06-28 11:12:32 +07:00
parent 607bae444e
commit 3d4745cb0a
7 changed files with 112 additions and 2 deletions

View File

@ -8,6 +8,7 @@
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use App\Models\Order; use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -15,6 +16,8 @@
class OrderRequest extends FormRequest class OrderRequest extends FormRequest
{ {
use ValidatesMediaUploads;
public function authorize(): bool public function authorize(): bool
{ {
$permission = $this->isMethod('POST') $permission = $this->isMethod('POST')
@ -42,6 +45,7 @@ public function rules(): array
'nego_price' => ['nullable', 'integer', 'min:0'], 'nego_price' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
'status' => ['nullable', Rule::enum(OrderStatus::class)], 'status' => ['nullable', Rule::enum(OrderStatus::class)],
...$this->photoRules(),
]; ];
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
@ -80,6 +84,7 @@ public function attributes(): array
'items.*.product_variant_id' => 'varian produk', 'items.*.product_variant_id' => 'varian produk',
'items.*.quantity' => 'jumlah', 'items.*.quantity' => 'jumlah',
'items.*.stock_quality' => 'kualitas stok', 'items.*.stock_quality' => 'kualitas stok',
...$this->photoUploadAttributes('bukti transaksi'),
]; ];
} }
@ -107,6 +112,26 @@ public function withValidator(Validator $validator): void
$validator->errors()->add('status', 'Pesanan tidak dapat diubah.'); $validator->errors()->add('status', 'Pesanan tidak dapat diubah.');
} }
} }
$paymentType = $this->input('payment_type');
if (in_array($paymentType, ['qris', 'transfer'], true)) {
$hasExistingPhotos = false;
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
/** @var Order $order */
$order = $this->route('order');
$hasExistingPhotos = $order->hasMedia('photos');
}
$hasNewPhotos = $this->hasFile('photos');
$isRemovingAll = ! empty($this->input('remove_media_ids')) && $hasExistingPhotos;
if (! $hasExistingPhotos && ! $hasNewPhotos) {
$validator->errors()->add('photos', 'Bukti transaksi wajib diisi untuk pembayaran QRIS/Transfer.');
} elseif ($isRemovingAll && ! $hasNewPhotos) {
$validator->errors()->add('photos', 'Bukti transaksi wajib diisi untuk pembayaran QRIS/Transfer.');
}
}
}); });
} }
} }

View File

@ -6,6 +6,7 @@
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -17,6 +18,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends([ #[Appends([
@ -32,9 +34,9 @@
'creator_name', 'creator_name',
'marketing_name', 'marketing_name',
])] ])]
class Order extends Model class Order extends Model implements HasMedia
{ {
use HasFactory, InteractsWithActivityLog, SoftDeletes; use HasFactory, HasModuleMedia, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array protected function casts(): array
{ {
@ -154,6 +156,16 @@ public function subtotalFormatted(): Attribute
); );
} }
public static function mediaModuleName(): string
{
return 'order';
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos');
}
#[Scope] #[Scope]
public function completed(Builder $query): void public function completed(Builder $query): void
{ {

View File

@ -15,6 +15,7 @@
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\User; use App\Models\User;
use App\Services\Finance\CashService; use App\Services\Finance\CashService;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Services\System\Setting\MarketplaceService; use App\Services\System\Setting\MarketplaceService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -28,11 +29,14 @@
class OrderService class OrderService
{ {
private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
private readonly MarketplaceService $marketplaceService, private readonly MarketplaceService $marketplaceService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly CashService $cashService, private readonly CashService $cashService,
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
private readonly MediaService $mediaService,
) {} ) {}
public function isEditable(OrderStatus $status): bool public function isEditable(OrderStatus $status): bool
@ -227,8 +231,14 @@ public function findForEdit(Order $order): Order
$order->load([ $order->load([
'items.productVariant.product:id,name', 'items.productVariant.product:id,name',
'items.productVariant.media', 'items.productVariant.media',
'media',
]); ]);
$order->setAttribute(
'photos',
MediaPresenter::first($order, 'photos'),
);
$variantIds = $order->items $variantIds = $order->items
->filter(fn (OrderItem $item) => $item->productVariant !== null) ->filter(fn (OrderItem $item) => $item->productVariant !== null)
->map(fn (OrderItem $item) => $item->productVariant->id) ->map(fn (OrderItem $item) => $item->productVariant->id)
@ -268,8 +278,14 @@ public function findForShow(Order $order): Order
'items.productVariant.product:id,name', 'items.productVariant.product:id,name',
'items.productVariant.media', 'items.productVariant.media',
'cashTransaction:id,amount,description,created_at', 'cashTransaction:id,amount,description,created_at',
'media',
]); ]);
$order->setAttribute(
'photos',
MediaPresenter::first($order, 'photos'),
);
$order->items->each(function (OrderItem $item): void { $order->items->each(function (OrderItem $item): void {
$variant = $item->productVariant; $variant = $item->productVariant;
@ -481,6 +497,8 @@ public function create(array $validated, User $user): Order
$order->save(); $order->save();
} }
$this->syncPhotos($order, $validated);
foreach ($draftItems as $item) { foreach ($draftItems as $item) {
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($item->product_variant_id); $variant = ProductVariant::query()->with('product')->lockForUpdate()->find($item->product_variant_id);
if ($variant === null) { if ($variant === null) {
@ -585,6 +603,8 @@ public function update(Order $order, array $validated): void
$order->save(); $order->save();
$this->syncPhotos($order, $validated);
foreach ($lineItems as $itemData) { foreach ($lineItems as $itemData) {
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($itemData['product_variant_id']); $variant = ProductVariant::query()->with('product')->lockForUpdate()->find($itemData['product_variant_id']);
if ($variant === null) { if ($variant === null) {
@ -845,6 +865,25 @@ private function availableStock(ProductVariant $variant, ProductStockQuality $st
return (int) $variant->{$this->stockColumn($stockQuality)}; return (int) $variant->{$this->stockColumn($stockQuality)};
} }
/**
* @param array<string, mixed> $validated
*/
private function syncPhotos(Order $order, array $validated): void
{
$paymentType = $validated['payment_type'] ?? null;
$requiresPhoto = in_array($paymentType, ['qris', 'transfer'], true);
$this->mediaService->syncCollection(
$order,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: $requiresPhoto,
errorKey: 'photos',
);
}
/** /**
* @param EloquentCollection<int, OrderItem>|list<array{quantity: int, subtotal: int}> $items * @param EloquentCollection<int, OrderItem>|list<array{quantity: int, subtotal: int}> $items
* @return list<array{quantity: int, subtotal: int}> * @return list<array{quantity: int, subtotal: int}>

View File

@ -39,6 +39,7 @@ const initialData = computed(() => ({
nego_price: props.order.nego_price != null ? String(props.order.nego_price) : '', nego_price: props.order.nego_price != null ? String(props.order.nego_price) : '',
notes: props.order.notes ?? '', notes: props.order.notes ?? '',
status: props.order.status, status: props.order.status,
photos: props.order.photos ?? null,
items: props.order.items.map((item) => ({ items: props.order.items.map((item) => ({
product_variant_id: item.product_variant_id, product_variant_id: item.product_variant_id,
product_name: item.product_name, product_name: item.product_name,

View File

@ -522,6 +522,21 @@ const feeEntries = computed(() => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Bukti Transaksi -->
<Card v-if="order.photos">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Receipt class="size-5" />
Bukti Transaksi
</CardTitle>
</CardHeader>
<CardContent>
<a :href="order.photos.url" target="_blank" class="block overflow-hidden rounded-md border">
<img :src="order.photos.thumb_url" alt="Bukti Transaksi" class="w-full object-cover">
</a>
</CardContent>
</Card>
</div> </div>
</div> </div>
</AdminLayout> </AdminLayout>

View File

@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { NumberInput } from '@/components/form/number-input'; import { NumberInput } from '@/components/form/number-input';
import { RupiahInput } from '@/components/form/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
@ -51,6 +52,8 @@ import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah'; import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items'; import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import { STOCK_QUALITY_OPTIONS, STOCK_QUALITY_OPTIONS_CASHIER } from '@/types/order'; import { STOCK_QUALITY_OPTIONS, STOCK_QUALITY_OPTIONS_CASHIER } from '@/types/order';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order'; import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product'; import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
@ -77,6 +80,7 @@ const props = defineProps<{
discount: string; discount: string;
nego_price: string; nego_price: string;
notes: string; notes: string;
photos?: MediaItem | null;
status: string; status: string;
items: OrderCartItem[]; items: OrderCartItem[];
}; };
@ -111,6 +115,7 @@ const customerFormOpen = ref(false);
const cartDetailOpen = ref(false); const cartDetailOpen = ref(false);
const printAfterSave = ref(false); const printAfterSave = ref(false);
const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80); const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80);
const photoState = ref<MediaUploadState>(createMediaUploadState());
function onCustomerCreated(customer: { id: number; name: string }) { function onCustomerCreated(customer: { id: number; name: string }) {
emit('customer-created', customer); emit('customer-created', customer);
@ -139,6 +144,7 @@ const form = useForm({
const isStoreChannel = computed(() => form.channel === 'store'); const isStoreChannel = computed(() => form.channel === 'store');
const isMarketplaceChannel = computed(() => form.channel === OrderChannel.SHOPEE || form.channel === OrderChannel.TIKTOK); const isMarketplaceChannel = computed(() => form.channel === OrderChannel.SHOPEE || form.channel === OrderChannel.TIKTOK);
const showPhotoInput = computed(() => form.payment_type === 'qris' || form.payment_type === 'transfer');
function populateForm() { function populateForm() {
if (!props.initialData) { if (!props.initialData) {
@ -157,6 +163,9 @@ function populateForm() {
form.nego_price = props.initialData.nego_price; form.nego_price = props.initialData.nego_price;
form.notes = props.initialData.notes; form.notes = props.initialData.notes;
form.status = props.initialData.status || OrderStatus.PENDING; form.status = props.initialData.status || OrderStatus.PENDING;
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : [],
);
cart.value = props.initialData.items.map((item) => ({ cart.value = props.initialData.items.map((item) => ({
...item, ...item,
stock_quality: item.stock_quality ?? StockQuality.GOOD, stock_quality: item.stock_quality ?? StockQuality.GOOD,
@ -488,6 +497,10 @@ function buildFormData(): FormData {
formData.append('notes', form.notes); formData.append('notes', form.notes);
formData.append('status', form.status); formData.append('status', form.status);
if (showPhotoInput.value) {
appendRootPhotosToFormData(formData, photoState.value);
}
if (isCreateMode.value && printAfterSave.value) { if (isCreateMode.value && printAfterSave.value) {
formData.append('print', '1'); formData.append('print', '1');
formData.append('paper_size', selectedPaperSize.value); formData.append('paper_size', selectedPaperSize.value);
@ -884,6 +897,9 @@ function submit() {
<FieldError :errors="formErrors(form, 'notes')" /> <FieldError :errors="formErrors(form, 'notes')" />
</Field> </Field>
<MediaDropzone v-if="showPhotoInput" id="order-photos" v-model="photoState" label="Bukti Transaksi"
:max-files="1" required :errors="formErrors(form, 'photos')" />
<Field v-if="isCreateMode && can('orders.create')"> <Field v-if="isCreateMode && can('orders.create')">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Switch id="print-after-save" :model-value="printAfterSave" <Switch id="print-after-save" :model-value="printAfterSave"

View File

@ -121,6 +121,7 @@ export type OrderEditItem = {
discount: number; discount: number;
nego_price: number | null; nego_price: number | null;
notes: string | null; notes: string | null;
photos?: MediaItem | null;
items: Array<{ items: Array<{
product_variant_id: number; product_variant_id: number;
product_name: string; product_name: string;
@ -210,6 +211,7 @@ export type OrderDetail = {
description: string; description: string;
created_at: string; created_at: string;
} | null; } | null;
photos?: MediaItem | null;
marketplace_settings_snapshot?: { marketplace_settings_snapshot?: {
platform: string; platform: string;
is_affiliate: boolean; is_affiliate: boolean;