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.
This commit is contained in:
Yoga Pangestu 2026-08-01 15:49:38 +07:00
parent f97e59bccf
commit 10db7bc5a2
13 changed files with 454 additions and 185 deletions

View File

@ -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',

View File

@ -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',

View File

@ -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']);
}
}

View File

@ -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

View File

@ -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<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="grid gap-2">
<div className="flex flex-wrap gap-3">
{value.map((item, index) => (
<div
key={item.key}
className="group relative h-24 w-24 overflow-hidden rounded-lg border bg-muted"
>
<img
src={item.url ?? undefined}
alt={`Foto ${index + 1}`}
className="h-full w-full object-cover"
/>
{index === 0 && (
<span className="absolute left-1 top-1 rounded bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
Utama
</span>
)}
<button
type="button"
onClick={() => handleRemove(index)}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{canAdd && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed bg-muted/50 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-muted"
>
{uploadingCount > 0 ? (
<Upload className="h-5 w-5 animate-pulse" />
) : (
<Plus className="h-5 w-5" />
)}
<span className="text-[10px]">
{uploadingCount > 0
? 'Mengunggah...'
: 'Tambah Foto'}
</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<p className="text-xs text-muted-foreground">
{value.length}/{maxItems} foto · Klik foto pertama sebagai
thumbnail utama
</p>
</div>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
setCurrentIndex(0);
}
onOpenChange(v);
}}
>
<DialogContent showCloseButton>
{title && (
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
)}
{src && (
<img
src={src}
alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain"
/>
{currentSrc && (
<div className="relative">
<img
src={currentSrc}
alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain"
/>
{hasMultiple && (
<>
<Button
variant="secondary"
size="icon"
className="absolute left-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handlePrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handleNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-1 text-xs text-white">
{currentIndex + 1} / {allImages.length}
</div>
</>
)}
</div>
)}
</DialogContent>
</Dialog>

View File

@ -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;

View File

@ -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;

View File

@ -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<VariantState[]>(() => {
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<string, unknown>)[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) {
*
</span>
</Label>
<FileUpload
<FileUploadMultiple
value={
variant.photo
}
existingUrl={
variant.photoUrl
variant.photos
}
onChange={(
photo,
) => {
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) {
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
`variants.${variantIndex}.photo_keys`
]
}
/>
@ -885,6 +893,7 @@ export default function ProductCreate({ categories }: Props) {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}

View File

@ -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<string, unknown>)[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) {
*
</span>
</Label>
<FileUpload
<FileUploadMultiple
value={
variant.photo
variant.photos
}
onChange={(
photo,
) => {
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) {
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
`variants.${variantIndex}.photo_keys`
]
}
/>
@ -965,6 +979,7 @@ export default function ProductEdit({ product, categories }: Props) {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}

View File

@ -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<string | null>(variant.photo_key);
const [photos, setPhotos] = useState<Array<{ key: string; url: string | null }>>(
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) {
<CardTitle>Foto Varian</CardTitle>
</CardHeader>
<CardContent>
<FileUpload
value={photo}
onChange={setPhoto}
<FileUploadMultiple
value={photos}
onChange={setPhotos}
folder="product-variant"
existingUrl={variant.photo_url}
maxItems={5}
onUploadingChange={setUploading}
/>
<InputError
message={errors.photo_key}
message={errors.photo_keys}
/>
</CardContent>
</Card>

View File

@ -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 (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
src={urls[0]}
alt={title}
className="h-full w-full object-cover"
/>
{urls.length > 1 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{urls.length}
</span>
)}
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
src={urls[0]}
sources={urls}
title={title}
/>
</>
@ -111,9 +117,9 @@ export function VariantSubRow({
{index + 1}
</TableCell>
<TableCell>
{variant.photo_url ? (
{variant.photo_urls?.length > 0 ? (
<VariantPhotoPreview
url={variant.photo_url}
urls={variant.photo_urls}
title={variant.name}
/>
) : (

View File

@ -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],
],