From 10db7bc5a253b7d013021c4bc7c427eddb30c660 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 1 Aug 2026 15:49:38 +0700 Subject: [PATCH] Refactor product variant photo handling to support multiple images - Updated ProductVariantService to handle multiple photo keys and URLs. - Introduced FileUploadMultiple component for uploading multiple images. - Modified product creation and editing forms to accommodate multiple photos. - Adjusted data structures in product drafts and tests to reflect changes in photo handling. - Enhanced image preview modal to navigate through multiple images. --- .../Admin/Master/Product/ProductRequest.php | 6 +- .../Master/Product/ProductVariantRequest.php | 6 +- .../Admin/Master/Product/ProductService.php | 24 ++- .../Master/Product/ProductVariantService.php | 18 +- .../js/components/file-upload-multiple.tsx | 156 ++++++++++++++++++ .../js/components/image-preview-modal.tsx | 71 +++++++- resources/js/lib/product-draft.ts | 11 +- .../js/pages/admin/master/product/columns.tsx | 2 +- .../js/pages/admin/master/product/create.tsx | 105 ++++++------ .../js/pages/admin/master/product/edit.tsx | 119 +++++++------ .../admin/master/product/variant/edit.tsx | 31 ++-- .../admin/master/product/variant/sub-row.tsx | 30 ++-- tests/Feature/Admin/Master/ProductTest.php | 60 +++---- 13 files changed, 454 insertions(+), 185 deletions(-) create mode 100644 resources/js/components/file-upload-multiple.tsx diff --git a/app/Http/Requests/Admin/Master/Product/ProductRequest.php b/app/Http/Requests/Admin/Master/Product/ProductRequest.php index b00bf9e..cefcbea 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductRequest.php @@ -64,7 +64,8 @@ public function rules(): array 'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.reject_stock' => ['required', 'integer', 'min:0'], 'variants.*.retail_stock' => ['required', 'integer', 'min:0'], - 'variants.*.photo_key' => ['required', 'string', 'max:500'], + 'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'], + 'variants.*.photo_keys.*' => ['required', 'string', 'max:500'], 'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])], 'variants.*.prices.*.id' => ['nullable', 'integer'], 'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())], @@ -84,7 +85,8 @@ public function attributes(): array 'variants.*.stock' => 'Stok', 'variants.*.reject_stock' => 'Stok Reject', 'variants.*.retail_stock' => 'Stok Retail', - 'variants.*.photo_key' => 'Foto', + 'variants.*.photo_keys' => 'Foto', + 'variants.*.photo_keys.*' => 'Foto', 'variants.*.prices' => 'Harga', 'variants.*.prices.*.type' => 'Tipe Harga', 'variants.*.prices.*.price' => 'Harga', diff --git a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php index 81004f3..7cc356d 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php @@ -32,7 +32,8 @@ public function rules(): array 'stock' => ['required', 'integer', 'min:0'], 'reject_stock' => ['required', 'integer', 'min:0'], 'retail_stock' => ['required', 'integer', 'min:0'], - 'photo_key' => ['required', 'string', 'max:500'], + 'photo_keys' => ['required', 'array', 'min:1', 'max:5'], + 'photo_keys.*' => ['required', 'string', 'max:500'], 'prices' => ['required', 'array', 'size:9'], 'prices.*.type' => ['required', Rule::in(PriceType::values())], 'prices.*.price' => ['required', 'integer', 'min:0'], @@ -46,7 +47,8 @@ public function attributes(): array 'stock' => 'Stok Bagus', 'reject_stock' => 'Stok Reject', 'retail_stock' => 'Stok Ecer', - 'photo_key' => 'Foto', + 'photo_keys' => 'Foto', + 'photo_keys.*' => 'Foto', 'prices' => 'Harga', 'prices.*.type' => 'Tipe Harga', 'prices.*.price' => 'Harga', diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index b6778ee..1510a56 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -6,6 +6,7 @@ use App\Models\ProductPrice; use App\Models\ProductVariant; use App\Services\NotificationService; +use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -14,6 +15,7 @@ class ProductService { public function __construct( private ProductVariantService $variantService = new ProductVariantService, + private S3PresignedService $s3Service = new S3PresignedService, ) {} public function getAll(array $filters = []): Collection @@ -32,7 +34,8 @@ public function getAll(array $filters = []): Collection $products->each(function ($product) { $product->productVariants->each(function ($variant) { - $variant->photo_url = $this->variantService->getTemporaryUrl($variant); + $media = $variant->getMedia('photos'); + $variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); }); }); @@ -64,7 +67,8 @@ public function paginated(int $perPage = 15, string $search = '', string $sort = $paginator->getCollection()->each(function ($product) { $product->productVariants->each(function ($variant) { - $variant->photo_url = $this->variantService->getTemporaryUrl($variant); + $media = $variant->getMedia('photos'); + $variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); }); }); @@ -104,8 +108,8 @@ public function create(array $data): Product ]); } - if (! empty($variantData['photo_key'])) { - $this->variantService->registerPhotos($variant, [$variantData['photo_key']]); + if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) { + $this->variantService->registerPhotos($variant, $variantData['photo_keys']); } } @@ -131,14 +135,18 @@ public function getForEdit(Product $product): array ]); $variants = $product->productVariants->map(function (ProductVariant $variant) { + $media = $variant->getMedia('photos'); + $photoKeys = $media->pluck('file_name')->toArray(); + $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); + return [ 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, 'reject_stock' => $variant->reject_stock, 'retail_stock' => $variant->retail_stock, - 'photo_key' => $variant->getMedia('photos')->first()?->file_name, - 'photo_url' => $this->variantService->getTemporaryUrl($variant), + 'photo_keys' => $photoKeys, + 'photo_urls' => $photoUrls, 'prices' => $variant->productPrices->map(fn ($p) => [ 'type' => $p->type->value, 'price' => $p->price, @@ -216,9 +224,9 @@ public function update(Product $product, array $data): Product ]); } - if (! empty($variantData['photo_key'])) { + if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) { $variant->clearMediaCollection('photos'); - $this->variantService->registerPhotos($variant, [$variantData['photo_key']]); + $this->variantService->registerPhotos($variant, $variantData['photo_keys']); } } diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 4a74d8d..7fda8ed 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -24,7 +24,9 @@ public function getForEdit(ProductVariant $variant): array { $variant->load('productPrices'); - $media = $variant->getMedia('photos')->first(); + $media = $variant->getMedia('photos'); + $photoKeys = $media->pluck('file_name')->toArray(); + $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); return [ 'id' => $variant->id, @@ -33,8 +35,8 @@ public function getForEdit(ProductVariant $variant): array 'stock' => $variant->stock, 'reject_stock' => $variant->reject_stock, 'retail_stock' => $variant->retail_stock, - 'photo_key' => $media?->file_name, - 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, + 'photo_keys' => $photoKeys, + 'photo_urls' => $photoUrls, 'prices' => $variant->productPrices->map(fn ($p) => [ 'type' => $p->type->value, 'price' => $p->price, @@ -62,9 +64,9 @@ public function update(ProductVariant $variant, array $data): ProductVariant ]); } - if (! empty($data['photo_key'])) { + if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) { $variant->clearMediaCollection('photos'); - $this->registerPhotos($variant, [$data['photo_key']]); + $this->registerPhotos($variant, $data['photo_keys']); } }); @@ -109,11 +111,11 @@ public function registerPhotos(ProductVariant $variant, array $photoKeys): void } } - public function getTemporaryUrl(ProductVariant $variant): ?string + public function getTemporaryUrls(ProductVariant $variant): array { - $media = $variant->getMedia('photos')->first(); + $media = $variant->getMedia('photos'); - return $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null; + return $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); } public function transferStock(ProductVariant $variant, array $data): ProductVariant diff --git a/resources/js/components/file-upload-multiple.tsx b/resources/js/components/file-upload-multiple.tsx new file mode 100644 index 0000000..993a6ba --- /dev/null +++ b/resources/js/components/file-upload-multiple.tsx @@ -0,0 +1,156 @@ +import { Plus, Upload, X } from 'lucide-react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { uploadFile, UploadError, getTemporaryUrl } from '@/lib/upload'; + +type FileUploadMultipleItem = { + key: string; + url: string | null; +}; + +type FileUploadMultipleProps = { + value: FileUploadMultipleItem[]; + onChange: (items: FileUploadMultipleItem[]) => void; + maxItems?: number; + folder?: string; + accept?: string; + onUploadingChange?: (uploading: boolean) => void; +}; + +export function FileUploadMultiple({ + value, + onChange, + maxItems = 5, + folder, + accept = 'image/jpeg,image/png,image/webp,image/gif', + onUploadingChange, +}: FileUploadMultipleProps) { + const inputRef = useRef(null); + const [error, setError] = useState(null); + const [uploadingCount, setUploadingCount] = useState(0); + + const onUploadingChangeRef = useRef(onUploadingChange); + + useEffect(() => { + onUploadingChangeRef.current = onUploadingChange; + }); + + useEffect(() => { + onUploadingChangeRef.current?.(uploadingCount > 0); + }, [uploadingCount]); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + if (value.length >= maxItems) { + setError(`Maksimal ${maxItems} gambar.`); + + return; + } + + setError(null); + setUploadingCount((prev) => prev + 1); + + try { + const key = await uploadFile(file, folder); + const newItem: FileUploadMultipleItem = { + key, + url: getTemporaryUrl(key), + }; + onChange([...value, newItem]); + } catch (err) { + const message = + err instanceof UploadError + ? err.message + : 'Gagal mengunggah file.'; + setError(message); + } finally { + setUploadingCount((prev) => prev - 1); + + if (inputRef.current) { + inputRef.current.value = ''; + } + } + }, + [value, maxItems, folder, onChange], + ); + + function handleRemove(index: number) { + const updated = value.filter((_, i) => i !== index); + onChange(updated); + setError(null); + } + + const canAdd = value.length < maxItems; + + return ( +
+
+ {value.map((item, index) => ( +
+ {`Foto + {index === 0 && ( + + Utama + + )} + +
+ ))} + + {canAdd && ( + + )} +
+ + + + {error && ( +

{error}

+ )} + +

+ {value.length}/{maxItems} foto ยท Klik foto pertama sebagai + thumbnail utama +

+
+ ); +} diff --git a/resources/js/components/image-preview-modal.tsx b/resources/js/components/image-preview-modal.tsx index 05321da..47f1587 100644 --- a/resources/js/components/image-preview-modal.tsx +++ b/resources/js/components/image-preview-modal.tsx @@ -1,3 +1,6 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -11,6 +14,7 @@ type ImagePreviewModalProps = { src: string | null; title?: string; alt?: string; + sources?: string[]; }; export function ImagePreviewModal({ @@ -19,21 +23,74 @@ export function ImagePreviewModal({ src, title, alt = 'Preview', + sources, }: ImagePreviewModalProps) { + const allImages = sources && sources.length > 0 ? sources : src ? [src] : []; + const [currentIndex, setCurrentIndex] = useState(0); + + const currentSrc = allImages[currentIndex] ?? src; + const hasMultiple = allImages.length > 1; + + function handlePrev() { + setCurrentIndex((prev) => + prev === 0 ? allImages.length - 1 : prev - 1, + ); + } + + function handleNext() { + setCurrentIndex((prev) => + prev === allImages.length - 1 ? 0 : prev + 1, + ); + } + return ( - + { + if (!v) { +setCurrentIndex(0); +} + + onOpenChange(v); + }} + > {title && ( {title} )} - {src && ( - {alt} + {currentSrc && ( +
+ {alt} + {hasMultiple && ( + <> + + +
+ {currentIndex + 1} / {allImages.length} +
+ + )} +
)}
diff --git a/resources/js/lib/product-draft.ts b/resources/js/lib/product-draft.ts index 751a545..25cd62d 100644 --- a/resources/js/lib/product-draft.ts +++ b/resources/js/lib/product-draft.ts @@ -13,7 +13,8 @@ export type ProductDraftData = { stock: number; reject_stock: number; retail_stock: number; - photo: string | null; + photos?: Array<{ key: string }>; + photo?: string; prices: Array<{ type: string; price: number }>; }>; }; @@ -26,6 +27,7 @@ function getKey( if (type === 'edit' && productId) { return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`; } + return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`; } @@ -38,6 +40,7 @@ export function saveProductDraft( try { const key = getKey(type, userId, productId); localStorage.setItem(key, JSON.stringify(data)); + return true; } catch { return false; @@ -52,7 +55,11 @@ export function loadProductDraft( try { const key = getKey(type, userId, productId); const raw = localStorage.getItem(key); - if (!raw) return null; + + if (!raw) { +return null; +} + return JSON.parse(raw) as ProductDraftData; } catch { return null; diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx index 6dcd8b4..c38045c 100644 --- a/resources/js/pages/admin/master/product/columns.tsx +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -16,7 +16,7 @@ export type ProductVariant = { stock: number; reject_stock: number; retail_stock: number; - photo_url: string | null; + photo_urls: string[]; product_prices: { id: number; type: string; diff --git a/resources/js/pages/admin/master/product/create.tsx b/resources/js/pages/admin/master/product/create.tsx index fb81181..3610c71 100644 --- a/resources/js/pages/admin/master/product/create.tsx +++ b/resources/js/pages/admin/master/product/create.tsx @@ -1,17 +1,3 @@ -import InputError from '@/components/input-error'; -import { RupiahInput } from '@/components/rupiah-input'; -import { FileUpload } from '@/components/file-upload'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Textarea } from '@/components/ui/textarea'; -import { loadProductDraft, clearProductDraft } from '@/lib/product-draft'; -import { useProductDraftSave } from '@/hooks/use-product-draft'; -import { getTemporaryUrl } from '@/lib/upload'; -import { index as productIndex, store } from '@/routes/admin/master/products'; import { Form, Head, router, usePage } from '@inertiajs/react'; import { ArrowLeft, @@ -22,6 +8,20 @@ import { Trash2, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUploadMultiple } from '@/components/file-upload-multiple'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { useProductDraftSave } from '@/hooks/use-product-draft'; +import { loadProductDraft, clearProductDraft } from '@/lib/product-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { index as productIndex, store } from '@/routes/admin/master/products'; type Category = { id: number; @@ -53,8 +53,7 @@ type VariantState = { stock: number; reject_stock: number; retail_stock: number; - photo: string | null; - photoUrl: string | null; + photos: Array<{ key: string; url: string | null }>; uploading: boolean; prices: Array<{ type: string; price: number }>; }; @@ -79,20 +78,28 @@ export default function ProductCreate({ categories }: Props) { >(draft?.sharedPrices ?? createEmptyPrices()); const [variants, setVariants] = useState(() => { if (draft?.variants && draft.variants.length > 0) { - return draft.variants.map((v) => ({ - ...v, - photoUrl: v.photo ? getTemporaryUrl(v.photo) : null, - uploading: false, - })); + return draft.variants.map((v) => { + const photos = Array.isArray(v.photos) + ? v.photos.map((p) => ({ key: p.key, url: getTemporaryUrl(p.key) })) + : typeof v.photo === 'string' && v.photo + ? [{ key: v.photo, url: getTemporaryUrl(v.photo) }] + : []; + + return { + ...v, + photos, + uploading: false, + }; + }); } + return [ { name: '', stock: 0, reject_stock: 0, retail_stock: 0, - photo: null, - photoUrl: null, + photos: [], uploading: false, prices: createEmptyPrices(), }, @@ -109,7 +116,14 @@ export default function ProductCreate({ categories }: Props) { categoryIds, useSamePrice, sharedPrices, - variants: variants.map(({ uploading, photoUrl, ...v }) => v), + variants: variants.map((v) => ({ + name: v.name, + stock: v.stock, + reject_stock: v.reject_stock, + retail_stock: v.retail_stock, + photos: v.photos.map((p) => ({ key: p.key })), + prices: v.prices, + })), }; useProductDraftSave('create', draftData, userId); @@ -128,8 +142,7 @@ export default function ProductCreate({ categories }: Props) { stock: 0, reject_stock: 0, retail_stock: 0, - photo: null, - photoUrl: null, + photos: [], uploading: false, prices: createEmptyPrices(), }, @@ -145,6 +158,7 @@ export default function ProductCreate({ categories }: Props) { setVariants((prev) => { const updated = [...prev]; (updated[index] as Record)[field] = value; + return updated; }); }, @@ -161,6 +175,7 @@ export default function ProductCreate({ categories }: Props) { i === priceIndex ? { ...p, price: value } : p, ), }; + return updated; }); }, @@ -172,6 +187,7 @@ export default function ProductCreate({ categories }: Props) { setSharedPrices((prev) => { const updated = [...prev]; updated[priceIndex] = { ...updated[priceIndex], price: value }; + return updated; }); }, @@ -195,6 +211,7 @@ export default function ProductCreate({ categories }: Props) { navigator.clipboard.writeText(JSON.stringify(prices)); setCopiedIndex(variantIndex); setTimeout(() => setCopiedIndex(null), 1500); + return prev; }); }, []); @@ -212,6 +229,7 @@ export default function ProductCreate({ categories }: Props) { ...updated[variantIndex], prices, }; + return updated; }); } catch { @@ -223,6 +241,7 @@ export default function ProductCreate({ categories }: Props) { const applyToAll = useCallback((variantIndex: number) => { setVariants((prev) => { const sourcePrices = prev[variantIndex].prices; + return prev.map((v, i) => i === variantIndex ? v : { ...v, prices: [...sourcePrices] }, ); @@ -247,7 +266,7 @@ export default function ProductCreate({ categories }: Props) { stock: Number(v.stock), reject_stock: Number(v.reject_stock), retail_stock: Number(v.retail_stock), - photo_key: v.photo, + photo_keys: v.photos.map((p) => p.key), prices: useSamePrice ? [] : v.prices.map((p) => ({ @@ -743,32 +762,21 @@ export default function ProductCreate({ categories }: Props) { * - { + photos, + ) => updateVariant( variantIndex, - 'photo', - photo, - ); - updateVariant( - variantIndex, - 'photoUrl', - photo - ? getTemporaryUrl( - photo, - ) - : null, - ); - }} + 'photos', + photos, + ) + } folder="product-variant" + maxItems={5} onUploadingChange={( uploading, ) => @@ -782,7 +790,7 @@ export default function ProductCreate({ categories }: Props) { @@ -885,6 +893,7 @@ export default function ProductCreate({ categories }: Props) { if (deleteVariantIndex !== null) { removeVariant(deleteVariantIndex); } + setDeleteConfirmOpen(false); setDeleteVariantIndex(null); }} diff --git a/resources/js/pages/admin/master/product/edit.tsx b/resources/js/pages/admin/master/product/edit.tsx index 8337c18..93ce89b 100644 --- a/resources/js/pages/admin/master/product/edit.tsx +++ b/resources/js/pages/admin/master/product/edit.tsx @@ -1,17 +1,3 @@ -import InputError from '@/components/input-error'; -import { RupiahInput } from '@/components/rupiah-input'; -import { FileUpload } from '@/components/file-upload'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Textarea } from '@/components/ui/textarea'; -import { loadProductDraft, clearProductDraft } from '@/lib/product-draft'; -import { useProductDraftSave } from '@/hooks/use-product-draft'; -import { getTemporaryUrl } from '@/lib/upload'; -import { index as productIndex, update } from '@/routes/admin/master/products'; import { Form, Head, router, usePage } from '@inertiajs/react'; import { ArrowLeft, @@ -22,6 +8,20 @@ import { Trash2, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUploadMultiple } from '@/components/file-upload-multiple'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { useProductDraftSave } from '@/hooks/use-product-draft'; +import { loadProductDraft, clearProductDraft } from '@/lib/product-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { index as productIndex, update } from '@/routes/admin/master/products'; type Category = { id: number; @@ -34,8 +34,8 @@ type ProductVariant = { stock: number; reject_stock: number; retail_stock: number; - photo_key: string | null; - photo_url: string | null; + photo_keys: string[]; + photo_urls: string[]; prices: Array<{ type: string; price: number }>; }; @@ -71,7 +71,10 @@ function arePricesEqual( a: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>, ): boolean { - if (a.length !== b.length) return false; + if (a.length !== b.length) { +return false; +} + return a.every( (pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price, ); @@ -83,8 +86,7 @@ type VariantState = { stock: number; reject_stock: number; retail_stock: number; - photo: string | null; - photoUrl: string | null; + photos: Array<{ key: string; url: string | null }>; uploading: boolean; prices: Array<{ type: string; price: number }>; }; @@ -110,8 +112,10 @@ export default function ProductEdit({ product, categories }: Props) { stock: v.stock, reject_stock: v.reject_stock, retail_stock: v.retail_stock, - photo: v.photo_key, - photoUrl: v.photo_url, + photos: v.photo_keys.map((key, i) => ({ + key, + url: v.photo_urls[i] ?? null, + })), uploading: false, prices: v.prices.length > 0 ? v.prices : createEmptyPrices(), }), @@ -141,24 +145,33 @@ export default function ProductEdit({ product, categories }: Props) { const serverVariantMap = new Map( serverVariants.map((sv) => [sv.id, sv]), ); + return draft.variants.map((v) => { const serverMatch = v.id != null ? serverVariantMap.get(v.id) : undefined; + + const photos = Array.isArray(v.photos) + ? v.photos.map((p, i) => ({ + key: p.key, + url: serverMatch?.photos[i]?.url ?? getTemporaryUrl(p.key), + })) + : typeof v.photo === 'string' && v.photo + ? [{ key: v.photo, url: serverMatch?.photos[0]?.url ?? getTemporaryUrl(v.photo) }] + : []; + return { id: v.id ?? null, name: v.name, stock: v.stock, reject_stock: v.reject_stock, retail_stock: v.retail_stock, - photo: v.photo, - photoUrl: - serverMatch?.photoUrl ?? - (v.photo ? getTemporaryUrl(v.photo) : null), + photos, uploading: false, prices: v.prices, }; }); } + return serverVariants.length > 0 ? serverVariants : [ @@ -168,8 +181,7 @@ export default function ProductEdit({ product, categories }: Props) { stock: 0, reject_stock: 0, retail_stock: 0, - photo: null, - photoUrl: null, + photos: [], uploading: false, prices: createEmptyPrices(), }, @@ -186,7 +198,15 @@ export default function ProductEdit({ product, categories }: Props) { categoryIds, useSamePrice, sharedPrices, - variants: variants.map(({ uploading, photoUrl, ...v }) => v), + variants: variants.map((v) => ({ + id: v.id, + name: v.name, + stock: v.stock, + reject_stock: v.reject_stock, + retail_stock: v.retail_stock, + photos: v.photos.map((p) => ({ key: p.key })), + prices: v.prices, + })), }; useProductDraftSave('edit', draftData, userId, product.id); @@ -206,8 +226,7 @@ export default function ProductEdit({ product, categories }: Props) { stock: 0, reject_stock: 0, retail_stock: 0, - photo: null, - photoUrl: null, + photos: [], uploading: false, prices: createEmptyPrices(), }, @@ -223,6 +242,7 @@ export default function ProductEdit({ product, categories }: Props) { setVariants((prev) => { const updated = [...prev]; (updated[index] as Record)[field] = value; + return updated; }); }, @@ -239,6 +259,7 @@ export default function ProductEdit({ product, categories }: Props) { i === priceIndex ? { ...p, price: value } : p, ), }; + return updated; }); }, @@ -250,6 +271,7 @@ export default function ProductEdit({ product, categories }: Props) { setSharedPrices((prev) => { const updated = [...prev]; updated[priceIndex] = { ...updated[priceIndex], price: value }; + return updated; }); }, @@ -273,6 +295,7 @@ export default function ProductEdit({ product, categories }: Props) { navigator.clipboard.writeText(JSON.stringify(prices)); setCopiedIndex(variantIndex); setTimeout(() => setCopiedIndex(null), 1500); + return prev; }); }, []); @@ -290,6 +313,7 @@ export default function ProductEdit({ product, categories }: Props) { ...updated[variantIndex], prices, }; + return updated; }); } catch { @@ -301,6 +325,7 @@ export default function ProductEdit({ product, categories }: Props) { const applyToAll = useCallback((variantIndex: number) => { setVariants((prev) => { const sourcePrices = prev[variantIndex].prices; + return prev.map((v, i) => i === variantIndex ? v : { ...v, prices: [...sourcePrices] }, ); @@ -326,7 +351,7 @@ export default function ProductEdit({ product, categories }: Props) { stock: Number(v.stock), reject_stock: Number(v.reject_stock), retail_stock: Number(v.retail_stock), - photo_key: v.photo, + photo_keys: v.photos.map((p) => p.key), prices: useSamePrice ? [] : v.prices.map((p) => ({ @@ -823,32 +848,21 @@ export default function ProductEdit({ product, categories }: Props) { * - { + photos, + ) => updateVariant( variantIndex, - 'photo', - photo, - ); - updateVariant( - variantIndex, - 'photoUrl', - photo - ? getTemporaryUrl( - photo, - ) - : null, - ); - }} - folder="product-variant" - existingUrl={ - variant.photoUrl + 'photos', + photos, + ) } + folder="product-variant" + maxItems={5} onUploadingChange={( uploading, ) => @@ -862,7 +876,7 @@ export default function ProductEdit({ product, categories }: Props) { @@ -965,6 +979,7 @@ export default function ProductEdit({ product, categories }: Props) { if (deleteVariantIndex !== null) { removeVariant(deleteVariantIndex); } + setDeleteConfirmOpen(false); setDeleteVariantIndex(null); }} diff --git a/resources/js/pages/admin/master/product/variant/edit.tsx b/resources/js/pages/admin/master/product/variant/edit.tsx index 701817d..3a1bb9b 100644 --- a/resources/js/pages/admin/master/product/variant/edit.tsx +++ b/resources/js/pages/admin/master/product/variant/edit.tsx @@ -1,14 +1,14 @@ +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import { FileUploadMultiple } from '@/components/file-upload-multiple'; import InputError from '@/components/input-error'; import { RupiahInput } from '@/components/rupiah-input'; -import { FileUpload } from '@/components/file-upload'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { index as productIndex } from '@/routes/admin/master/products'; -import { Form, Head } from '@inertiajs/react'; -import { ArrowLeft } from 'lucide-react'; -import { useState } from 'react'; type Props = { variant: { @@ -18,8 +18,8 @@ type Props = { stock: number; reject_stock: number; retail_stock: number; - photo_key: string | null; - photo_url: string | null; + photo_keys: string[]; + photo_urls: string[]; prices: Array<{ type: string; price: number }>; }; }; @@ -41,7 +41,12 @@ export default function ProductVariantEdit({ variant }: Props) { const [stock, setStock] = useState(variant.stock); const [rejectStock, setRejectStock] = useState(variant.reject_stock); const [retailStock, setRetailStock] = useState(variant.retail_stock); - const [photo, setPhoto] = useState(variant.photo_key); + const [photos, setPhotos] = useState>( + variant.photo_keys.map((key, i) => ({ + key, + url: variant.photo_urls[i] ?? null, + })), + ); const [uploading, setUploading] = useState(false); const [prices, setPrices] = useState< Array<{ type: string; price: number }> @@ -63,7 +68,7 @@ export default function ProductVariantEdit({ variant }: Props) { stock: Number(stock), reject_stock: Number(rejectStock), retail_stock: Number(retailStock), - photo_key: photo, + photo_keys: photos.map((p) => p.key), prices: prices.map((p) => ({ type: p.type, price: Number(p.price), @@ -188,15 +193,15 @@ export default function ProductVariantEdit({ variant }: Props) { Foto Varian - diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx index 0a327fe..99a4eb3 100644 --- a/resources/js/pages/admin/master/product/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx @@ -2,12 +2,6 @@ import { ArrowRightLeft, Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { ImagePreviewModal } from '@/components/image-preview-modal'; import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { Table, TableBody, @@ -16,6 +10,12 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import type { Product, ProductVariant } from '../columns'; import { TransferStockDialog } from './transfer-stock-dialog'; @@ -31,25 +31,31 @@ function formatNumber(num: number): string { return new Intl.NumberFormat('id-ID').format(num); } -function VariantPhotoPreview({ url, title }: { url: string; title: string }) { +function VariantPhotoPreview({ urls, title }: { urls: string[]; title: string }) { const [open, setOpen] = useState(false); return ( <> @@ -111,9 +117,9 @@ export function VariantSubRow({ {index + 1} - {variant.photo_url ? ( + {variant.photo_urls?.length > 0 ? ( ) : ( diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php index 1b6cac7..e2c983a 100644 --- a/tests/Feature/Admin/Master/ProductTest.php +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -35,7 +35,7 @@ function makeValidProductPayload(array $overrides = []): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/test-photo.jpg', + 'photo_keys' => ['product-variant/test-photo.jpg'], 'prices' => [ ['type' => 'distributor', 'price' => 10000], ['type' => 'agent', 'price' => 11000], @@ -79,7 +79,7 @@ function makeValidSharedPricePayload(array $overrides = []): array 'stock' => 50, 'reject_stock' => 5, 'retail_stock' => 10, - 'photo_key' => 'product-variant/shared-photo.jpg', + 'photo_keys' => ['product-variant/shared-photo.jpg'], 'prices' => [], ], ], @@ -378,7 +378,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 1, 'retail_stock' => 2, - 'photo_key' => 'product-variant/v1.jpg', + 'photo_keys' => ['product-variant/v1.jpg'], 'prices' => allPriceTypes(), ], [ @@ -386,7 +386,7 @@ function allPriceTypes(): array 'stock' => 20, 'reject_stock' => 2, 'retail_stock' => 4, - 'photo_key' => 'product-variant/v2.jpg', + 'photo_keys' => ['product-variant/v2.jpg'], 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()), ], ], @@ -408,7 +408,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/free.jpg', + 'photo_keys' => ['product-variant/free.jpg'], 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()), ]], ])); @@ -430,7 +430,7 @@ function allPriceTypes(): array 'stock' => 1, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/expensive.jpg', + 'photo_keys' => ['product-variant/expensive.jpg'], 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()), ]], ])); @@ -448,7 +448,7 @@ function allPriceTypes(): array 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/empty.jpg', + 'photo_keys' => ['product-variant/empty.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -695,15 +695,15 @@ function allPriceTypes(): array $response->assertSessionHasErrors('variants.0.retail_stock'); }); -test('product variant photo_key is required', function () { +test('product variant photo_keys is required', function () { $user = User::factory()->create(); $this->actingAs($user); $payload = makeValidProductPayload(); - unset($payload['variants'][0]['photo_key']); + unset($payload['variants'][0]['photo_keys']); $response = $this->post(route('admin.master.products.store'), $payload); - $response->assertSessionHasErrors('variants.0.photo_key'); + $response->assertSessionHasErrors('variants.0.photo_keys'); }); /* @@ -912,7 +912,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/existing.jpg', + 'photo_keys' => ['product-variant/existing.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -995,7 +995,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/v1.jpg', + 'photo_keys' => ['product-variant/v1.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1028,7 +1028,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/v1.jpg', + 'photo_keys' => ['product-variant/v1.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1057,7 +1057,7 @@ function allPriceTypes(): array 'stock' => 42, 'reject_stock' => 3, 'retail_stock' => 7, - 'photo_key' => 'product-variant/new.jpg', + 'photo_keys' => ['product-variant/new.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1087,7 +1087,7 @@ function allPriceTypes(): array 'stock' => 999, 'reject_stock' => 50, 'retail_stock' => 75, - 'photo_key' => 'product-variant/updated.jpg', + 'photo_keys' => ['product-variant/updated.jpg'], 'prices' => allPriceTypes(), ], ], @@ -1433,7 +1433,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/weird.jpg', + 'photo_keys' => ['product-variant/weird.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1451,7 +1451,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/maxlen.jpg', + 'photo_keys' => ['product-variant/maxlen.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1469,7 +1469,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/overmax.jpg', + 'photo_keys' => ['product-variant/overmax.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1487,7 +1487,7 @@ function allPriceTypes(): array 'stock' => 2147483647, 'reject_stock' => 2147483647, 'retail_stock' => 2147483647, - 'photo_key' => 'product-variant/huge.jpg', + 'photo_keys' => ['product-variant/huge.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1505,7 +1505,7 @@ function allPriceTypes(): array 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, - 'photo_key' => 'product-variant/whitespace.jpg', + 'photo_keys' => ['product-variant/whitespace.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1763,7 +1763,7 @@ function allPriceTypes(): array 'stock' => $variant->stock, 'reject_stock' => $variant->reject_stock, 'retail_stock' => $variant->retail_stock, - 'photo_key' => 'product-variant/final.jpg', + 'photo_keys' => ['product-variant/final.jpg'], 'prices' => allPriceTypes(), ]], ])); @@ -1824,7 +1824,7 @@ function allPriceTypes(): array 'stock' => $i * 10, 'reject_stock' => $i, 'retail_stock' => $i * 2, - 'photo_key' => "product-variant/v{$i}.jpg", + 'photo_keys' => ["product-variant/v{$i}.jpg"], 'prices' => allPriceTypes(), ]; } @@ -1955,7 +1955,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/all-prices.jpg', + 'photo_keys' => ['product-variant/all-prices.jpg'], 'prices' => $prices, ]], ])); @@ -1976,8 +1976,8 @@ function allPriceTypes(): array $response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([ 'variants' => [ - ['name' => 'V1', 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_key' => 'v1.jpg', 'prices' => []], - ['name' => 'V2', 'stock' => 20, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_key' => 'v2.jpg', 'prices' => []], + ['name' => 'V1', 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_keys' => ['v1.jpg'], 'prices' => []], + ['name' => 'V2', 'stock' => 20, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_keys' => ['v2.jpg'], 'prices' => []], ], ])); @@ -2076,7 +2076,7 @@ function allPriceTypes(): array 'stock' => 50, 'reject_stock' => 5, 'retail_stock' => 10, - 'photo_key' => 'product-variant/updated.jpg', + 'photo_keys' => ['product-variant/updated.jpg'], 'prices' => allPriceTypes(), ]); @@ -2098,7 +2098,7 @@ function allPriceTypes(): array 'stock' => 200, 'reject_stock' => 15, 'retail_stock' => 25, - 'photo_key' => 'product-variant/new-photo.jpg', + 'photo_keys' => ['product-variant/new-photo.jpg'], 'prices' => allPriceTypes(), ]); @@ -2132,7 +2132,7 @@ function allPriceTypes(): array 'stock' => $variant->stock, 'reject_stock' => $variant->reject_stock, 'retail_stock' => $variant->retail_stock, - 'photo_key' => 'product-variant/test.jpg', + 'photo_keys' => ['product-variant/test.jpg'], 'prices' => allPriceTypes(), ]); @@ -2152,7 +2152,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/test.jpg', + 'photo_keys' => ['product-variant/test.jpg'], 'prices' => allPriceTypes(), ]); @@ -2171,7 +2171,7 @@ function allPriceTypes(): array 'stock' => 100, 'reject_stock' => 10, 'retail_stock' => 20, - 'photo_key' => 'product-variant/test.jpg', + 'photo_keys' => ['product-variant/test.jpg'], 'prices' => [ ['type' => 'retail', 'price' => 10000], ],