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'],
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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 ?? ''}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user