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

This commit is contained in:
Yoga Pangestu 2026-08-14 22:34:13 +07:00
parent 703cdb8b8b
commit 018c3adf12
8 changed files with 84 additions and 61 deletions

View File

@ -28,7 +28,8 @@ public function rules(): array
'materials.*.combination_index' => ['nullable', 'integer'], 'materials.*.combination_index' => ['nullable', 'integer'],
'combinations' => ['nullable', 'array'], 'combinations' => ['nullable', 'array'],
'combinations.*.material_result' => ['nullable', 'integer'], 'combinations.*.material_result' => ['nullable', 'integer'],
'photo_key' => ['nullable', 'string', 'max:500'], 'photo_keys' => ['nullable', 'array', 'max:5'],
'photo_keys.*' => ['required', 'string', 'max:500'],
]; ];
} }
@ -47,7 +48,8 @@ public function attributes(): array
'materials.*.combination_index' => 'indeks kombinasi', 'materials.*.combination_index' => 'indeks kombinasi',
'combinations' => 'kombinasi', 'combinations' => 'kombinasi',
'combinations.*.material_result' => 'hasil kombinasi', 'combinations.*.material_result' => 'hasil kombinasi',
'photo_key' => 'foto', 'photo_keys' => 'foto',
'photo_keys.*' => 'foto',
]; ];
} }
} }

View File

@ -52,13 +52,13 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
$paginator->getCollection()->each(function (Cutting $cutting) { $paginator->getCollection()->each(function (Cutting $cutting) {
$cuttingMedia = $cutting->getFirstMedia('images'); $cuttingMedia = $cutting->getMedia('images');
$cutting->photo_url = $cuttingMedia $cutting->photo_urls = $cuttingMedia->map(
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath()) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
: null; )->toArray();
$cutting->photo_conversion_url = $cuttingMedia $cutting->photo_conversion_urls = $cuttingMedia->map(
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb')) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; )->toArray();
}); });
return $paginator; return $paginator;
@ -118,13 +118,13 @@ public function getForShow(Cutting $cutting): array
'cuttingMaterialCombinations:id,cutting_id,material_result', 'cuttingMaterialCombinations:id,cutting_id,material_result',
]); ]);
$cuttingMedia = $cutting->getFirstMedia('images'); $cuttingMedia = $cutting->getMedia('images');
$cutting->photo_url = $cuttingMedia $cutting->photo_urls = $cuttingMedia->map(
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath()) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
: null; )->toArray();
$cutting->photo_conversion_url = $cuttingMedia $cutting->photo_conversion_urls = $cuttingMedia->map(
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb')) fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; )->toArray();
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) { $cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('images'); $media = $material->rawMaterialPrice?->getFirstMedia('images');
@ -149,8 +149,8 @@ public function getForShow(Cutting $cutting): array
'formatted_cost_per_unit' => $cutting->formatted_cost_per_unit, 'formatted_cost_per_unit' => $cutting->formatted_cost_per_unit,
'created_at' => $cutting->created_at, 'created_at' => $cutting->created_at,
'formatted_created_at' => $cutting->formatted_created_at, 'formatted_created_at' => $cutting->formatted_created_at,
'photo_url' => $cutting->photo_url, 'photo_urls' => $cutting->photo_urls,
'photo_conversion_url' => $cutting->photo_conversion_url, 'photo_conversion_urls' => $cutting->photo_conversion_urls,
'created_by' => [ 'created_by' => [
'id' => $cutting->createdBy->id, 'id' => $cutting->createdBy->id,
'user_profile' => [ 'user_profile' => [
@ -228,11 +228,13 @@ public function getForEdit(Cutting $cutting): array
]; ];
}); });
$cuttingMedia = $cutting->getFirstMedia('images'); $cuttingMedia = $cutting->getMedia('images');
$photoKey = $cuttingMedia?->getCustomProperty('s3_key') ?? $cuttingMedia?->file_name; $photoKeys = $cuttingMedia->map(
$photoUrl = $cuttingMedia fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath()) )->toArray();
: null; $photoUrls = $cuttingMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
return [ return [
'id' => $cutting->id, 'id' => $cutting->id,
@ -244,8 +246,8 @@ public function getForEdit(Cutting $cutting): array
'cutting_result' => $result?->cutting_result ?? 0, 'cutting_result' => $result?->cutting_result ?? 0,
'materials' => $materials, 'materials' => $materials,
'combinations' => $combinations, 'combinations' => $combinations,
'photo_key' => $photoKey, 'photo_keys' => $photoKeys,
'photo_url' => $photoUrl, 'photo_urls' => $photoUrls,
]; ];
} }
@ -338,12 +340,11 @@ public function store(array $data): Cutting
'updated_at' => $now, 'updated_at' => $now,
]); ]);
if (! empty($data['photo_key'])) { if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$this->registerMedia( $this->registerPhotos(
model: $cutting, model: $cutting,
s3Key: $data['photo_key'], photoKeys: $data['photo_keys'],
collectionName: 'images', collectionName: 'images',
orderColumn: 1,
); );
} }
@ -458,7 +459,7 @@ public function update(Cutting $cutting, array $data): Cutting
'updated_at' => $now, 'updated_at' => $now,
]); ]);
$this->syncPhoto($cutting, $data); $this->syncPhotos($cutting, $data['photo_keys'] ?? [], 'images');
return $cutting; return $cutting;
}); });

View File

@ -19,7 +19,7 @@ export type CuttingDraftData = {
material_result: number | null; material_result: number | null;
}>; }>;
selectedMaterialName?: string; selectedMaterialName?: string;
photo?: string; photo_keys?: string[];
}; };
export const cuttingDraftStore = export const cuttingDraftStore =

View File

@ -29,8 +29,8 @@ export type Cutting = {
formatted_total_material_cost: string | null; formatted_total_material_cost: string | null;
cost_per_unit: number | null; cost_per_unit: number | null;
formatted_cost_per_unit: string | null; formatted_cost_per_unit: string | null;
photo_url: string | null; photo_urls: string[];
photo_conversion_url: string | null; photo_conversion_urls: string[];
created_at: string; created_at: string;
formatted_created_at: string; formatted_created_at: string;
materials_count: number; materials_count: number;
@ -89,8 +89,8 @@ export type CuttingForEdit = {
material_result: number | null; material_result: number | null;
material_indices: number[]; material_indices: number[];
}[]; }[];
photo_key: string | null; photo_keys: string[];
photo_url: string | null; photo_urls: string[];
}; };
export type CuttingCreateData = { export type CuttingCreateData = {

View File

@ -5,7 +5,7 @@ import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lu
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 { 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';
@ -83,10 +83,15 @@ export default function CuttingCreate({ rawMaterials }: Props) {
const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0); const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0);
const cuttingResult = sample + originalOutsideSample; const cuttingResult = sample + originalOutsideSample;
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 submittingRef = useRef(false); const submittingRef = useRef(false);
@ -130,9 +135,9 @@ export default function CuttingCreate({ rawMaterials }: Props) {
material_result: c.material_result, material_result: c.material_result,
})), })),
selectedMaterialName, selectedMaterialName,
photo: photo ?? undefined, photo_keys: photos.map((p) => p.key),
}), }),
[productName, sample, originalOutsideSample, notes, materials, combinations, selectedMaterialName, photo], [productName, sample, originalOutsideSample, notes, materials, combinations, selectedMaterialName, photos],
); );
useCuttingDraftSave('create', draftData, userId); useCuttingDraftSave('create', draftData, userId);
@ -306,7 +311,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
combinations: combinations.map((c) => ({ combinations: combinations.map((c) => ({
material_result: c.material_result, material_result: c.material_result,
})), })),
photo_key: photo, photo_keys: photos.map((p) => p.key),
}; };
} }
@ -527,10 +532,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { <FileUploadMultiple
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); value={photos}
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> onChange={setPhotos}
<InputError message={errors.photo_key} /> folder="cutting"
maxItems={5}
onUploadingChange={setUploading}
/>
<InputError message={errors.photo_keys as string} />
</div> </div>
<Button type="submit" className="w-full" disabled={processing}> <Button type="submit" className="w-full" disabled={processing}>

View File

@ -208,11 +208,11 @@ export function CuttingCardRow({
)} )}
</div> </div>
{cutting.photo_url && ( {cutting.photo_urls && cutting.photo_urls.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<ImagePreviewButton <ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]} srcs={cutting.photo_conversion_urls?.length ? cutting.photo_conversion_urls : cutting.photo_urls}
modalSrc={cutting.photo_url} modalSrcs={cutting.photo_urls}
title={productName} title={productName}
description={cutting.description ?? cutting.formatted_created_at} description={cutting.description ?? cutting.formatted_created_at}
className="h-16 w-16" className="h-16 w-16"

View File

@ -5,7 +5,7 @@ import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lu
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 { 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';
@ -93,8 +93,15 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample); const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample);
const cuttingResult = sample + originalOutsideSample; const cuttingResult = sample + originalOutsideSample;
const [notes, setNotes] = useState(cutting.description ?? ''); const [notes, setNotes] = useState(cutting.description ?? '');
const [photo, setPhoto] = useState<string | null>(cutting.photo_key); const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
const [photoUrl, setPhotoUrl] = useState<string | null>(cutting.photo_url); if (cutting.photo_keys && cutting.photo_keys.length > 0) {
return cutting.photo_keys.map((key: string, index: number) => ({
key,
url: cutting.photo_urls?.[index] ?? getTemporaryUrl(key),
}));
}
return [];
});
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const submittingRef = useRef(false); const submittingRef = useRef(false);
@ -282,7 +289,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
combinations: combinations.map((c) => ({ combinations: combinations.map((c) => ({
material_result: c.material_result, material_result: c.material_result,
})), })),
photo_key: photo, photo_keys: photos.map((p) => p.key),
}; };
} }
@ -503,10 +510,14 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { <FileUploadMultiple
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); value={photos}
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> onChange={setPhotos}
<InputError message={errors.photo_key} /> folder="cutting"
maxItems={5}
onUploadingChange={setUploading}
/>
<InputError message={errors.photo_keys as string} />
</div> </div>
<Button type="submit" className="w-full" disabled={processing}> <Button type="submit" className="w-full" disabled={processing}>

View File

@ -77,11 +77,11 @@ export default function CuttingShow({ cutting }: Props) {
<p className="mt-3 text-sm">{cutting.description}</p> <p className="mt-3 text-sm">{cutting.description}</p>
)} )}
{cutting.photo_url && ( {cutting.photo_urls && cutting.photo_urls.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<ImagePreviewButton <ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]} srcs={cutting.photo_conversion_urls?.length ? cutting.photo_conversion_urls : cutting.photo_urls}
modalSrc={cutting.photo_url} modalSrcs={cutting.photo_urls}
title={result?.product_name ?? `Cutting #${cutting.id}`} title={result?.product_name ?? `Cutting #${cutting.id}`}
description={cutting.description ?? ''} description={cutting.description ?? ''}
/> />