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'],
'shipping_cost' => ['nullable', 'integer', 'min:0'],
'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',
'shipping_cost' => 'ongkir',
'notes' => 'keterangan',
'photo_key' => 'foto',
'photo_keys' => 'foto',
'photo_keys.*' => 'foto',
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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