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\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.');
}
}
});
}
}

View File

@ -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
{

View File

@ -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<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
* @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) : '',
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,

View File

@ -522,6 +522,21 @@ const feeEntries = computed(() => {
</CardContent>
</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>
</AdminLayout>

View File

@ -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<MediaUploadState>(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() {
<FieldError :errors="formErrors(form, 'notes')" />
</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')">
<div class="flex items-center gap-3">
<Switch id="print-after-save" :model-value="printAfterSave"

View File

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