feat: update photo handling to support multiple images in PurchaseRequest and related components

This commit is contained in:
Yoga Pangestu 2026-08-14 22:59:21 +07:00
parent 018c3adf12
commit 8c26f942bf
7 changed files with 71 additions and 68 deletions

View File

@ -41,7 +41,8 @@ public function rules(): array
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'], 'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => ['nullable', 'string', 'max:500'], 'photo_keys' => ['nullable', 'array', 'max:5'],
'photo_keys.*' => ['required', 'string', 'max:500'],
]; ];
} }
@ -63,7 +64,8 @@ public function attributes(): array
'discount' => 'diskon', 'discount' => 'diskon',
'shipping_cost' => 'ongkir', 'shipping_cost' => 'ongkir',
'notes' => 'keterangan', 'notes' => 'keterangan',
'photo_key' => 'foto', 'photo_keys' => 'foto',
'photo_keys.*' => 'foto',
]; ];
} }
} }

View File

@ -12,6 +12,7 @@
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class PurchaseService class PurchaseService
@ -49,19 +50,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
$paginator->getCollection()->each(function (Purchase $purchase) { $paginator->getCollection()->each(function (Purchase $purchase) {
$purchaseMedia = $purchase->getFirstMedia('photos'); $purchaseMedia = $purchase->getMedia('photos');
$purchase->photo_url = $purchaseMedia $purchase->photo_urls = $purchaseMedia->map(
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath()) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
: null; )->toArray();
$purchase->photo_conversion_url = $purchaseMedia $purchase->photo_conversion_urls = $purchaseMedia->map(
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb')) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; )->toArray();
}); });
return $paginator; return $paginator;
} }
public function getItems(Purchase $purchase): \Illuminate\Support\Collection public function getItems(Purchase $purchase): Collection
{ {
return $purchase->purchaseItems() return $purchase->purchaseItems()
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal']) ->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
@ -146,11 +147,13 @@ public function getForEdit(Purchase $purchase): array
->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id')) ->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id'))
->exists(); ->exists();
$purchaseMedia = $purchase->getFirstMedia('photos'); $purchaseMedia = $purchase->getMedia('photos');
$purchasePhotoKey = $purchaseMedia?->getCustomProperty('s3_key') ?? $purchaseMedia?->file_name; $purchasePhotoKeys = $purchaseMedia->map(
$purchasePhotoUrl = $purchaseMedia fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath()) )->toArray();
: null; $purchasePhotoUrls = $purchaseMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
return [ return [
'id' => $purchase->id, 'id' => $purchase->id,
@ -160,8 +163,8 @@ public function getForEdit(Purchase $purchase): array
'discount' => $purchase->discount, 'discount' => $purchase->discount,
'shipping_cost' => $purchase->shipping_cost, 'shipping_cost' => $purchase->shipping_cost,
'notes' => $purchase->notes, 'notes' => $purchase->notes,
'photo_key' => $purchasePhotoKey, 'photo_keys' => $purchasePhotoKeys,
'photo_url' => $purchasePhotoUrl, 'photo_urls' => $purchasePhotoUrls,
'variants' => $variants, 'variants' => $variants,
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing', 'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
'existing_material_name' => $singleMaterial ? $materials->first()->name : null, 'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
@ -228,12 +231,11 @@ private function storeFromExisting(array $data): Purchase
->increment('stock', (int) $item['quantity']); ->increment('stock', (int) $item['quantity']);
} }
if (! empty($data['photo_key'])) { if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$this->registerMedia( $this->registerPhotos(
model: $purchase, model: $purchase,
s3Key: $data['photo_key'], photoKeys: $data['photo_keys'],
collectionName: 'photos', collectionName: 'photos',
orderColumn: 1,
); );
} }
@ -318,12 +320,11 @@ private function storeNew(array $data): Purchase
DB::table('purchase_items')->insert($purchaseItems); DB::table('purchase_items')->insert($purchaseItems);
if (! empty($data['photo_key'])) { if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$this->registerMedia( $this->registerPhotos(
model: $purchase, model: $purchase,
s3Key: $data['photo_key'], photoKeys: $data['photo_keys'],
collectionName: 'photos', collectionName: 'photos',
orderColumn: 1,
); );
} }
@ -474,7 +475,7 @@ public function update(Purchase $purchase, array $data): Purchase
} }
DB::table('purchase_items')->insert($itemRows); DB::table('purchase_items')->insert($itemRows);
$this->syncPhoto($purchase, $data); $this->syncPhotos($purchase, $data['photo_keys'] ?? [], 'photos');
return $purchase; return $purchase;
}); });

View File

@ -11,7 +11,7 @@ export type PurchaseDraftData = {
variant: string; variant: string;
price: number; price: number;
stock: number; stock: number;
photo?: string; photo_keys?: string[];
}>; }>;
mode?: 'new' | 'existing'; mode?: 'new' | 'existing';
selectedMaterialName?: string; selectedMaterialName?: string;

View File

@ -16,8 +16,8 @@ export type Purchase = {
shipping_cost: number; shipping_cost: number;
total: number; total: number;
notes: string | null; notes: string | null;
photo_url: string | null; photo_urls: string[];
photo_conversion_url: string | null; photo_conversion_urls: string[];
created_at: string; created_at: string;
variants_count: number; variants_count: number;
total_qty: number; total_qty: number;
@ -65,8 +65,8 @@ export type PurchaseForEdit = {
discount: number; discount: number;
shipping_cost: number; shipping_cost: number;
notes: string | null; notes: string | null;
photo_key: string | null; photo_keys: string[];
photo_url: string | null; photo_urls: string[];
variants: { variants: {
id: number; id: number;
variant: string; variant: string;

View File

@ -14,7 +14,7 @@ import {
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs'; import { FileUpload, FileUploadMultiple } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs'; import { NumberInput } from '@/components/inputs';
@ -114,10 +114,15 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const [discount, setDiscount] = useState(draft?.discount ?? 0); const [discount, setDiscount] = useState(draft?.discount ?? 0);
const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0); const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0);
const [notes, setNotes] = useState(draft?.notes ?? ''); const [notes, setNotes] = useState(draft?.notes ?? '');
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null); const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
const [photoUrl, setPhotoUrl] = useState<string | null>( if (draft?.photo_keys && draft.photo_keys.length > 0) {
draft?.photo ? getTemporaryUrl(draft.photo) : null, return draft.photo_keys.map((key: string) => ({
); key,
url: getTemporaryUrl(key),
}));
}
return [];
});
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new'); const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new');
@ -158,7 +163,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
qty, qty,
]), ]),
), ),
photo: photo ?? undefined, photo_keys: photos.map((p) => p.key),
}), }),
[ [
name, name,
@ -171,7 +176,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
mode, mode,
selectedMaterialName, selectedMaterialName,
quantities, quantities,
photo, photos,
], ],
); );
@ -390,7 +395,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
discount, discount,
shipping_cost: shippingCost, shipping_cost: shippingCost,
notes: notes || null, notes: notes || null,
photo_key: photo, photo_keys: photos.map((p) => p.key),
}; };
if (mode === 'existing') { if (mode === 'existing') {
@ -1099,21 +1104,15 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload <FileUploadMultiple
value={photo} value={photos}
onChange={(key) => { onChange={setPhotos}
setPhoto(key);
const url = key
? `https://dstpabuaran.s3.ap-southeast-1.amazonaws.com/${key}`
: null;
setPhotoUrl(url);
}}
folder="purchase" folder="purchase"
existingUrl={photoUrl} maxItems={5}
onUploadingChange={setUploading} onUploadingChange={setUploading}
/> />
<InputError <InputError
message={errors.photo_key} message={errors.photo_keys as string}
/> />
</div> </div>

View File

@ -11,7 +11,7 @@ import {
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs'; import { FileUploadMultiple } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs'; import { NumberInput } from '@/components/inputs';
@ -73,8 +73,15 @@ export default function PurchaseEdit({
const [discount, setDiscount] = useState(purchase.discount); const [discount, setDiscount] = useState(purchase.discount);
const [shippingCost, setShippingCost] = useState(purchase.shipping_cost); const [shippingCost, setShippingCost] = useState(purchase.shipping_cost);
const [notes, setNotes] = useState(purchase.notes ?? ''); const [notes, setNotes] = useState(purchase.notes ?? '');
const [photo, setPhoto] = useState<string | null>(purchase.photo_key); const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url); if (purchase.photo_keys && purchase.photo_keys.length > 0) {
return purchase.photo_keys.map((key: string, index: number) => ({
key,
url: purchase.photo_urls?.[index] ?? getTemporaryUrl(key),
}));
}
return [];
});
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [selectedMaterialName, setSelectedMaterialName] = useState( const [selectedMaterialName, setSelectedMaterialName] = useState(
@ -185,7 +192,7 @@ export default function PurchaseEdit({
discount, discount,
shipping_cost: shippingCost, shipping_cost: shippingCost,
notes: notes || null, notes: notes || null,
photo_key: photo, photo_keys: photos.map((p) => p.key),
existing_items: Object.entries(quantities) existing_items: Object.entries(quantities)
.map(([priceId, quantity]) => ({ .map(([priceId, quantity]) => ({
raw_material_price_id: Number(priceId), raw_material_price_id: Number(priceId),
@ -538,21 +545,15 @@ export default function PurchaseEdit({
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload <FileUploadMultiple
value={photo} value={photos}
onChange={(key) => { onChange={setPhotos}
setPhoto(key);
const url = key
? `https://dstpabuaran.s3.ap-southeast-1.amazonaws.com/${key}`
: null;
setPhotoUrl(url);
}}
folder="purchase" folder="purchase"
existingUrl={photoUrl} maxItems={5}
onUploadingChange={setUploading} onUploadingChange={setUploading}
/> />
<InputError <InputError
message={errors.photo_key} message={errors.photo_keys as string}
/> />
</div> </div>

View File

@ -110,11 +110,11 @@ export function PurchaseCardRow({
</span> </span>
</div> </div>
{purchase.photo_url && ( {purchase.photo_urls && purchase.photo_urls.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<ImagePreviewButton <ImagePreviewButton
srcs={[purchase.photo_conversion_url ?? purchase.photo_url]} srcs={purchase.photo_conversion_urls?.length ? purchase.photo_conversion_urls : purchase.photo_urls}
modalSrc={purchase.photo_url} modalSrcs={purchase.photo_urls}
title={purchase.supplier.name} title={purchase.supplier.name}
description={formatDateTime(purchase.created_at)} description={formatDateTime(purchase.created_at)}
className="h-16 w-16" className="h-16 w-16"