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\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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user