diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php index 09ef1d5..5779a42 100644 --- a/app/Http/Requests/Admin/Manage/OrderRequest.php +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -8,6 +8,7 @@ use App\Enums\Permission; use App\Enums\PriceType; use App\Enums\ProductStockQuality; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use App\Models\Order; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -15,6 +16,8 @@ class OrderRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $permission = $this->isMethod('POST') @@ -42,6 +45,7 @@ public function rules(): array 'nego_price' => ['nullable', 'integer', 'min:0'], 'notes' => ['nullable', 'string'], 'status' => ['nullable', Rule::enum(OrderStatus::class)], + ...$this->photoRules(), ]; if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { @@ -80,6 +84,7 @@ public function attributes(): array 'items.*.product_variant_id' => 'varian produk', 'items.*.quantity' => 'jumlah', '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.'); } } + + $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.'); + } + } }); } } diff --git a/app/Models/Order.php b/app/Models/Order.php index 080e576..c87b235 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -6,6 +6,7 @@ use App\Enums\OrderStatus; use App\Enums\PaymentType; use App\Enums\PriceType; +use App\Models\Concerns\HasModuleMedia; use App\Models\Concerns\InteractsWithActivityLog; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; @@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends([ @@ -32,9 +34,9 @@ 'creator_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 { @@ -154,6 +156,16 @@ public function subtotalFormatted(): Attribute ); } + public static function mediaModuleName(): string + { + return 'order'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + #[Scope] public function completed(Builder $query): void { diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index 2adf610..eec91af 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -15,6 +15,7 @@ use App\Models\ProductVariant; use App\Models\User; use App\Services\Finance\CashService; +use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; use App\Services\System\Setting\MarketplaceService; use App\Support\Media\MediaPresenter; @@ -28,11 +29,14 @@ class OrderService { + private const MAX_PHOTOS = 1; + public function __construct( private readonly MarketplaceService $marketplaceService, private readonly PushNotificationService $pushNotificationService, private readonly CashService $cashService, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, + private readonly MediaService $mediaService, ) {} public function isEditable(OrderStatus $status): bool @@ -227,8 +231,14 @@ public function findForEdit(Order $order): Order $order->load([ 'items.productVariant.product:id,name', 'items.productVariant.media', + 'media', ]); + $order->setAttribute( + 'photos', + MediaPresenter::first($order, 'photos'), + ); + $variantIds = $order->items ->filter(fn (OrderItem $item) => $item->productVariant !== null) ->map(fn (OrderItem $item) => $item->productVariant->id) @@ -268,8 +278,14 @@ public function findForShow(Order $order): Order 'items.productVariant.product:id,name', 'items.productVariant.media', 'cashTransaction:id,amount,description,created_at', + 'media', ]); + $order->setAttribute( + 'photos', + MediaPresenter::first($order, 'photos'), + ); + $order->items->each(function (OrderItem $item): void { $variant = $item->productVariant; @@ -481,6 +497,8 @@ public function create(array $validated, User $user): Order $order->save(); } + $this->syncPhotos($order, $validated); + foreach ($draftItems as $item) { $variant = ProductVariant::query()->with('product')->lockForUpdate()->find($item->product_variant_id); if ($variant === null) { @@ -585,6 +603,8 @@ public function update(Order $order, array $validated): void $order->save(); + $this->syncPhotos($order, $validated); + foreach ($lineItems as $itemData) { $variant = ProductVariant::query()->with('product')->lockForUpdate()->find($itemData['product_variant_id']); if ($variant === null) { @@ -845,6 +865,25 @@ private function availableStock(ProductVariant $variant, ProductStockQuality $st return (int) $variant->{$this->stockColumn($stockQuality)}; } + /** + * @param array $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|list $items * @return list diff --git a/resources/js/pages/admin/manage/orders/Edit.vue b/resources/js/pages/admin/manage/orders/Edit.vue index cbe8482..81d7e01 100644 --- a/resources/js/pages/admin/manage/orders/Edit.vue +++ b/resources/js/pages/admin/manage/orders/Edit.vue @@ -39,6 +39,7 @@ const initialData = computed(() => ({ nego_price: props.order.nego_price != null ? String(props.order.nego_price) : '', notes: props.order.notes ?? '', status: props.order.status, + photos: props.order.photos ?? null, items: props.order.items.map((item) => ({ product_variant_id: item.product_variant_id, product_name: item.product_name, diff --git a/resources/js/pages/admin/manage/orders/Show.vue b/resources/js/pages/admin/manage/orders/Show.vue index 318c3f1..36d891b 100644 --- a/resources/js/pages/admin/manage/orders/Show.vue +++ b/resources/js/pages/admin/manage/orders/Show.vue @@ -522,6 +522,21 @@ const feeEntries = computed(() => { + + + + + + Bukti Transaksi + + + + + Bukti Transaksi + + + + diff --git a/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue b/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue index 4f58ee0..460b5ac 100644 --- a/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue +++ b/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue @@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue'; import { toast } from 'vue-sonner'; import { NumberInput } from '@/components/form/number-input'; import { RupiahInput } from '@/components/form/rupiah-input'; +import MediaDropzone from '@/components/media/MediaDropzone.vue'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -51,6 +52,8 @@ import { formErrors } from '@/lib/form'; 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 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 type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order'; import type { ProductPriceItem, ProductVariantItem } from '@/types/product'; @@ -77,6 +80,7 @@ const props = defineProps<{ discount: string; nego_price: string; notes: string; + photos?: MediaItem | null; status: string; items: OrderCartItem[]; }; @@ -111,6 +115,7 @@ const customerFormOpen = ref(false); const cartDetailOpen = ref(false); const printAfterSave = ref(false); const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80); +const photoState = ref(createMediaUploadState()); function onCustomerCreated(customer: { id: number; name: string }) { emit('customer-created', customer); @@ -139,6 +144,7 @@ const form = useForm({ const isStoreChannel = computed(() => form.channel === 'store'); const isMarketplaceChannel = computed(() => form.channel === OrderChannel.SHOPEE || form.channel === OrderChannel.TIKTOK); +const showPhotoInput = computed(() => form.payment_type === 'qris' || form.payment_type === 'transfer'); function populateForm() { if (!props.initialData) { @@ -157,6 +163,9 @@ function populateForm() { form.nego_price = props.initialData.nego_price; form.notes = props.initialData.notes; form.status = props.initialData.status || OrderStatus.PENDING; + photoState.value = createMediaUploadState( + props.initialData.photos ? [props.initialData.photos] : [], + ); cart.value = props.initialData.items.map((item) => ({ ...item, stock_quality: item.stock_quality ?? StockQuality.GOOD, @@ -488,6 +497,10 @@ function buildFormData(): FormData { formData.append('notes', form.notes); formData.append('status', form.status); + if (showPhotoInput.value) { + appendRootPhotosToFormData(formData, photoState.value); + } + if (isCreateMode.value && printAfterSave.value) { formData.append('print', '1'); formData.append('paper_size', selectedPaperSize.value); @@ -884,6 +897,9 @@ function submit() { + +