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

View File

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

View File

@ -29,8 +29,8 @@ export type Cutting = {
formatted_total_material_cost: string | null;
cost_per_unit: number | null;
formatted_cost_per_unit: string | null;
photo_url: string | null;
photo_conversion_url: string | null;
photo_urls: string[];
photo_conversion_urls: string[];
created_at: string;
formatted_created_at: string;
materials_count: number;
@ -89,8 +89,8 @@ export type CuttingForEdit = {
material_result: number | null;
material_indices: number[];
}[];
photo_key: string | null;
photo_url: string | null;
photo_keys: string[];
photo_urls: string[];
};
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 { 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';
@ -83,10 +83,15 @@ export default function CuttingCreate({ rawMaterials }: Props) {
const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0);
const cuttingResult = sample + originalOutsideSample;
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 submittingRef = useRef(false);
@ -130,9 +135,9 @@ export default function CuttingCreate({ rawMaterials }: Props) {
material_result: c.material_result,
})),
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);
@ -306,7 +311,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
combinations: combinations.map((c) => ({
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">
<Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} />
<FileUploadMultiple
value={photos}
onChange={setPhotos}
folder="cutting"
maxItems={5}
onUploadingChange={setUploading}
/>
<InputError message={errors.photo_keys as string} />
</div>
<Button type="submit" className="w-full" disabled={processing}>

View File

@ -208,11 +208,11 @@ export function CuttingCardRow({
)}
</div>
{cutting.photo_url && (
{cutting.photo_urls && cutting.photo_urls.length > 0 && (
<div className="mt-2">
<ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url}
srcs={cutting.photo_conversion_urls?.length ? cutting.photo_conversion_urls : cutting.photo_urls}
modalSrcs={cutting.photo_urls}
title={productName}
description={cutting.description ?? cutting.formatted_created_at}
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 { 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';
@ -93,8 +93,15 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample);
const cuttingResult = sample + originalOutsideSample;
const [notes, setNotes] = useState(cutting.description ?? '');
const [photo, setPhoto] = useState<string | null>(cutting.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(cutting.photo_url);
const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
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 submittingRef = useRef(false);
@ -282,7 +289,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
combinations: combinations.map((c) => ({
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">
<Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} />
<FileUploadMultiple
value={photos}
onChange={setPhotos}
folder="cutting"
maxItems={5}
onUploadingChange={setUploading}
/>
<InputError message={errors.photo_keys as string} />
</div>
<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>
)}
{cutting.photo_url && (
{cutting.photo_urls && cutting.photo_urls.length > 0 && (
<div className="mt-4">
<ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url}
srcs={cutting.photo_conversion_urls?.length ? cutting.photo_conversion_urls : cutting.photo_urls}
modalSrcs={cutting.photo_urls}
title={result?.product_name ?? `Cutting #${cutting.id}`}
description={cutting.description ?? ''}
/>