feat: update photo handling to support multiple images in CuttingRequest and related components
This commit is contained in:
parent
703cdb8b8b
commit
018c3adf12
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ export type CuttingDraftData = {
|
||||
material_result: number | null;
|
||||
}>;
|
||||
selectedMaterialName?: string;
|
||||
photo?: string;
|
||||
photo_keys?: string[];
|
||||
};
|
||||
|
||||
export const cuttingDraftStore =
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 ?? ''}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user