feat: integrate media upload handling for order transactions, update validation and frontend components for photo requirements
This commit is contained in:
parent
607bae444e
commit
3d4745cb0a
@ -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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user