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.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.reject_stock' => ['required', 'integer', 'min:0'], 'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_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' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'], 'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())], '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.*.stock' => 'Stok',
'variants.*.reject_stock' => 'Stok Reject', 'variants.*.reject_stock' => 'Stok Reject',
'variants.*.retail_stock' => 'Stok Retail', 'variants.*.retail_stock' => 'Stok Retail',
'variants.*.photo_key' => 'Foto', 'variants.*.photo_keys' => 'Foto',
'variants.*.photo_keys.*' => 'Foto',
'variants.*.prices' => 'Harga', 'variants.*.prices' => 'Harga',
'variants.*.prices.*.type' => 'Tipe Harga', 'variants.*.prices.*.type' => 'Tipe Harga',
'variants.*.prices.*.price' => 'Harga', 'variants.*.prices.*.price' => 'Harga',

View File

@ -32,7 +32,8 @@ public function rules(): array
'stock' => ['required', 'integer', 'min:0'], 'stock' => ['required', 'integer', 'min:0'],
'reject_stock' => ['required', 'integer', 'min:0'], 'reject_stock' => ['required', 'integer', 'min:0'],
'retail_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' => ['required', 'array', 'size:9'],
'prices.*.type' => ['required', Rule::in(PriceType::values())], 'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'], 'prices.*.price' => ['required', 'integer', 'min:0'],
@ -46,7 +47,8 @@ public function attributes(): array
'stock' => 'Stok Bagus', 'stock' => 'Stok Bagus',
'reject_stock' => 'Stok Reject', 'reject_stock' => 'Stok Reject',
'retail_stock' => 'Stok Ecer', 'retail_stock' => 'Stok Ecer',
'photo_key' => 'Foto', 'photo_keys' => 'Foto',
'photo_keys.*' => 'Foto',
'prices' => 'Harga', 'prices' => 'Harga',
'prices.*.type' => 'Tipe Harga', 'prices.*.type' => 'Tipe Harga',
'prices.*.price' => 'Harga', 'prices.*.price' => 'Harga',

View File

@ -6,6 +6,7 @@
use App\Models\ProductPrice; use App\Models\ProductPrice;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -14,6 +15,7 @@ class ProductService
{ {
public function __construct( public function __construct(
private ProductVariantService $variantService = new ProductVariantService, private ProductVariantService $variantService = new ProductVariantService,
private S3PresignedService $s3Service = new S3PresignedService,
) {} ) {}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
@ -32,7 +34,8 @@ public function getAll(array $filters = []): Collection
$products->each(function ($product) { $products->each(function ($product) {
$product->productVariants->each(function ($variant) { $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) { $paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) { $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'])) { if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
$this->variantService->registerPhotos($variant, [$variantData['photo_key']]); $this->variantService->registerPhotos($variant, $variantData['photo_keys']);
} }
} }
@ -131,14 +135,18 @@ public function getForEdit(Product $product): array
]); ]);
$variants = $product->productVariants->map(function (ProductVariant $variant) { $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 [ return [
'id' => $variant->id, 'id' => $variant->id,
'name' => $variant->name, 'name' => $variant->name,
'stock' => $variant->stock, 'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock, 'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_key' => $variant->getMedia('photos')->first()?->file_name, 'photo_keys' => $photoKeys,
'photo_url' => $this->variantService->getTemporaryUrl($variant), 'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn ($p) => [ 'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value, 'type' => $p->type->value,
'price' => $p->price, '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'); $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'); $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 [ return [
'id' => $variant->id, 'id' => $variant->id,
@ -33,8 +35,8 @@ public function getForEdit(ProductVariant $variant): array
'stock' => $variant->stock, 'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock, 'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_key' => $media?->file_name, 'photo_keys' => $photoKeys,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, 'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn ($p) => [ 'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value, 'type' => $p->type->value,
'price' => $p->price, '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'); $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 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,6 +14,7 @@ type ImagePreviewModalProps = {
src: string | null; src: string | null;
title?: string; title?: string;
alt?: string; alt?: string;
sources?: string[];
}; };
export function ImagePreviewModal({ export function ImagePreviewModal({
@ -19,21 +23,74 @@ export function ImagePreviewModal({
src, src,
title, title,
alt = 'Preview', alt = 'Preview',
sources,
}: ImagePreviewModalProps) { }: 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
setCurrentIndex(0);
}
onOpenChange(v);
}}
>
<DialogContent showCloseButton> <DialogContent showCloseButton>
{title && ( {title && (
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
)} )}
{src && ( {currentSrc && (
<div className="relative">
<img <img
src={src} src={currentSrc}
alt={alt} alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain" 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> </DialogContent>
</Dialog> </Dialog>

View File

@ -13,7 +13,8 @@ export type ProductDraftData = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo: string | null; photos?: Array<{ key: string }>;
photo?: string;
prices: Array<{ type: string; price: number }>; prices: Array<{ type: string; price: number }>;
}>; }>;
}; };
@ -26,6 +27,7 @@ function getKey(
if (type === 'edit' && productId) { if (type === 'edit' && productId) {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`; return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`;
} }
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`; return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
} }
@ -38,6 +40,7 @@ export function saveProductDraft(
try { try {
const key = getKey(type, userId, productId); const key = getKey(type, userId, productId);
localStorage.setItem(key, JSON.stringify(data)); localStorage.setItem(key, JSON.stringify(data));
return true; return true;
} catch { } catch {
return false; return false;
@ -52,7 +55,11 @@ export function loadProductDraft(
try { try {
const key = getKey(type, userId, productId); const key = getKey(type, userId, productId);
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
if (!raw) return null;
if (!raw) {
return null;
}
return JSON.parse(raw) as ProductDraftData; return JSON.parse(raw) as ProductDraftData;
} catch { } catch {
return null; return null;

View File

@ -16,7 +16,7 @@ export type ProductVariant = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo_url: string | null; photo_urls: string[];
product_prices: { product_prices: {
id: number; id: number;
type: string; 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 { Form, Head, router, usePage } from '@inertiajs/react';
import { import {
ArrowLeft, ArrowLeft,
@ -22,6 +8,20 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from '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 = { type Category = {
id: number; id: number;
@ -53,8 +53,7 @@ type VariantState = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo: string | null; photos: Array<{ key: string; url: string | null }>;
photoUrl: string | null;
uploading: boolean; uploading: boolean;
prices: Array<{ type: string; price: number }>; prices: Array<{ type: string; price: number }>;
}; };
@ -79,20 +78,28 @@ export default function ProductCreate({ categories }: Props) {
>(draft?.sharedPrices ?? createEmptyPrices()); >(draft?.sharedPrices ?? createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>(() => { const [variants, setVariants] = useState<VariantState[]>(() => {
if (draft?.variants && draft.variants.length > 0) { if (draft?.variants && draft.variants.length > 0) {
return draft.variants.map((v) => ({ 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, ...v,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null, photos,
uploading: false, uploading: false,
})); };
});
} }
return [ return [
{ {
name: '', name: '',
stock: 0, stock: 0,
reject_stock: 0, reject_stock: 0,
retail_stock: 0, retail_stock: 0,
photo: null, photos: [],
photoUrl: null,
uploading: false, uploading: false,
prices: createEmptyPrices(), prices: createEmptyPrices(),
}, },
@ -109,7 +116,14 @@ export default function ProductCreate({ categories }: Props) {
categoryIds, categoryIds,
useSamePrice, useSamePrice,
sharedPrices, 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); useProductDraftSave('create', draftData, userId);
@ -128,8 +142,7 @@ export default function ProductCreate({ categories }: Props) {
stock: 0, stock: 0,
reject_stock: 0, reject_stock: 0,
retail_stock: 0, retail_stock: 0,
photo: null, photos: [],
photoUrl: null,
uploading: false, uploading: false,
prices: createEmptyPrices(), prices: createEmptyPrices(),
}, },
@ -145,6 +158,7 @@ export default function ProductCreate({ categories }: Props) {
setVariants((prev) => { setVariants((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -161,6 +175,7 @@ export default function ProductCreate({ categories }: Props) {
i === priceIndex ? { ...p, price: value } : p, i === priceIndex ? { ...p, price: value } : p,
), ),
}; };
return updated; return updated;
}); });
}, },
@ -172,6 +187,7 @@ export default function ProductCreate({ categories }: Props) {
setSharedPrices((prev) => { setSharedPrices((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value }; updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated; return updated;
}); });
}, },
@ -195,6 +211,7 @@ export default function ProductCreate({ categories }: Props) {
navigator.clipboard.writeText(JSON.stringify(prices)); navigator.clipboard.writeText(JSON.stringify(prices));
setCopiedIndex(variantIndex); setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500); setTimeout(() => setCopiedIndex(null), 1500);
return prev; return prev;
}); });
}, []); }, []);
@ -212,6 +229,7 @@ export default function ProductCreate({ categories }: Props) {
...updated[variantIndex], ...updated[variantIndex],
prices, prices,
}; };
return updated; return updated;
}); });
} catch { } catch {
@ -223,6 +241,7 @@ export default function ProductCreate({ categories }: Props) {
const applyToAll = useCallback((variantIndex: number) => { const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => { setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices; const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) => return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }, i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
); );
@ -247,7 +266,7 @@ export default function ProductCreate({ categories }: Props) {
stock: Number(v.stock), stock: Number(v.stock),
reject_stock: Number(v.reject_stock), reject_stock: Number(v.reject_stock),
retail_stock: Number(v.retail_stock), retail_stock: Number(v.retail_stock),
photo_key: v.photo, photo_keys: v.photos.map((p) => p.key),
prices: useSamePrice prices: useSamePrice
? [] ? []
: v.prices.map((p) => ({ : v.prices.map((p) => ({
@ -743,32 +762,21 @@ export default function ProductCreate({ categories }: Props) {
* *
</span> </span>
</Label> </Label>
<FileUpload <FileUploadMultiple
value={ value={
variant.photo variant.photos
}
existingUrl={
variant.photoUrl
} }
onChange={( onChange={(
photo, photos,
) => { ) =>
updateVariant( updateVariant(
variantIndex, variantIndex,
'photo', 'photos',
photo, photos,
);
updateVariant(
variantIndex,
'photoUrl',
photo
? getTemporaryUrl(
photo,
) )
: null, }
);
}}
folder="product-variant" folder="product-variant"
maxItems={5}
onUploadingChange={( onUploadingChange={(
uploading, uploading,
) => ) =>
@ -782,7 +790,7 @@ export default function ProductCreate({ categories }: Props) {
<InputError <InputError
message={ message={
errors[ errors[
`variants.${variantIndex}.photo_key` `variants.${variantIndex}.photo_keys`
] ]
} }
/> />
@ -885,6 +893,7 @@ export default function ProductCreate({ categories }: Props) {
if (deleteVariantIndex !== null) { if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex); removeVariant(deleteVariantIndex);
} }
setDeleteConfirmOpen(false); setDeleteConfirmOpen(false);
setDeleteVariantIndex(null); 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 { Form, Head, router, usePage } from '@inertiajs/react';
import { import {
ArrowLeft, ArrowLeft,
@ -22,6 +8,20 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from '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 = { type Category = {
id: number; id: number;
@ -34,8 +34,8 @@ type ProductVariant = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo_key: string | null; photo_keys: string[];
photo_url: string | null; photo_urls: string[];
prices: Array<{ type: string; price: number }>; prices: Array<{ type: string; price: number }>;
}; };
@ -71,7 +71,10 @@ function arePricesEqual(
a: Array<{ type: string; price: number }>, a: Array<{ type: string; price: number }>,
b: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>,
): boolean { ): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) {
return false;
}
return a.every( return a.every(
(pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price, (pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price,
); );
@ -83,8 +86,7 @@ type VariantState = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo: string | null; photos: Array<{ key: string; url: string | null }>;
photoUrl: string | null;
uploading: boolean; uploading: boolean;
prices: Array<{ type: string; price: number }>; prices: Array<{ type: string; price: number }>;
}; };
@ -110,8 +112,10 @@ export default function ProductEdit({ product, categories }: Props) {
stock: v.stock, stock: v.stock,
reject_stock: v.reject_stock, reject_stock: v.reject_stock,
retail_stock: v.retail_stock, retail_stock: v.retail_stock,
photo: v.photo_key, photos: v.photo_keys.map((key, i) => ({
photoUrl: v.photo_url, key,
url: v.photo_urls[i] ?? null,
})),
uploading: false, uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(), prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}), }),
@ -141,24 +145,33 @@ export default function ProductEdit({ product, categories }: Props) {
const serverVariantMap = new Map( const serverVariantMap = new Map(
serverVariants.map((sv) => [sv.id, sv]), serverVariants.map((sv) => [sv.id, sv]),
); );
return draft.variants.map((v) => { return draft.variants.map((v) => {
const serverMatch = const serverMatch =
v.id != null ? serverVariantMap.get(v.id) : undefined; 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 { return {
id: v.id ?? null, id: v.id ?? null,
name: v.name, name: v.name,
stock: v.stock, stock: v.stock,
reject_stock: v.reject_stock, reject_stock: v.reject_stock,
retail_stock: v.retail_stock, retail_stock: v.retail_stock,
photo: v.photo, photos,
photoUrl:
serverMatch?.photoUrl ??
(v.photo ? getTemporaryUrl(v.photo) : null),
uploading: false, uploading: false,
prices: v.prices, prices: v.prices,
}; };
}); });
} }
return serverVariants.length > 0 return serverVariants.length > 0
? serverVariants ? serverVariants
: [ : [
@ -168,8 +181,7 @@ export default function ProductEdit({ product, categories }: Props) {
stock: 0, stock: 0,
reject_stock: 0, reject_stock: 0,
retail_stock: 0, retail_stock: 0,
photo: null, photos: [],
photoUrl: null,
uploading: false, uploading: false,
prices: createEmptyPrices(), prices: createEmptyPrices(),
}, },
@ -186,7 +198,15 @@ export default function ProductEdit({ product, categories }: Props) {
categoryIds, categoryIds,
useSamePrice, useSamePrice,
sharedPrices, 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); useProductDraftSave('edit', draftData, userId, product.id);
@ -206,8 +226,7 @@ export default function ProductEdit({ product, categories }: Props) {
stock: 0, stock: 0,
reject_stock: 0, reject_stock: 0,
retail_stock: 0, retail_stock: 0,
photo: null, photos: [],
photoUrl: null,
uploading: false, uploading: false,
prices: createEmptyPrices(), prices: createEmptyPrices(),
}, },
@ -223,6 +242,7 @@ export default function ProductEdit({ product, categories }: Props) {
setVariants((prev) => { setVariants((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -239,6 +259,7 @@ export default function ProductEdit({ product, categories }: Props) {
i === priceIndex ? { ...p, price: value } : p, i === priceIndex ? { ...p, price: value } : p,
), ),
}; };
return updated; return updated;
}); });
}, },
@ -250,6 +271,7 @@ export default function ProductEdit({ product, categories }: Props) {
setSharedPrices((prev) => { setSharedPrices((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value }; updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated; return updated;
}); });
}, },
@ -273,6 +295,7 @@ export default function ProductEdit({ product, categories }: Props) {
navigator.clipboard.writeText(JSON.stringify(prices)); navigator.clipboard.writeText(JSON.stringify(prices));
setCopiedIndex(variantIndex); setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500); setTimeout(() => setCopiedIndex(null), 1500);
return prev; return prev;
}); });
}, []); }, []);
@ -290,6 +313,7 @@ export default function ProductEdit({ product, categories }: Props) {
...updated[variantIndex], ...updated[variantIndex],
prices, prices,
}; };
return updated; return updated;
}); });
} catch { } catch {
@ -301,6 +325,7 @@ export default function ProductEdit({ product, categories }: Props) {
const applyToAll = useCallback((variantIndex: number) => { const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => { setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices; const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) => return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }, i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
); );
@ -326,7 +351,7 @@ export default function ProductEdit({ product, categories }: Props) {
stock: Number(v.stock), stock: Number(v.stock),
reject_stock: Number(v.reject_stock), reject_stock: Number(v.reject_stock),
retail_stock: Number(v.retail_stock), retail_stock: Number(v.retail_stock),
photo_key: v.photo, photo_keys: v.photos.map((p) => p.key),
prices: useSamePrice prices: useSamePrice
? [] ? []
: v.prices.map((p) => ({ : v.prices.map((p) => ({
@ -823,32 +848,21 @@ export default function ProductEdit({ product, categories }: Props) {
* *
</span> </span>
</Label> </Label>
<FileUpload <FileUploadMultiple
value={ value={
variant.photo variant.photos
} }
onChange={( onChange={(
photo, photos,
) => { ) =>
updateVariant( updateVariant(
variantIndex, variantIndex,
'photo', 'photos',
photo, photos,
);
updateVariant(
variantIndex,
'photoUrl',
photo
? getTemporaryUrl(
photo,
) )
: null,
);
}}
folder="product-variant"
existingUrl={
variant.photoUrl
} }
folder="product-variant"
maxItems={5}
onUploadingChange={( onUploadingChange={(
uploading, uploading,
) => ) =>
@ -862,7 +876,7 @@ export default function ProductEdit({ product, categories }: Props) {
<InputError <InputError
message={ message={
errors[ errors[
`variants.${variantIndex}.photo_key` `variants.${variantIndex}.photo_keys`
] ]
} }
/> />
@ -965,6 +979,7 @@ export default function ProductEdit({ product, categories }: Props) {
if (deleteVariantIndex !== null) { if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex); removeVariant(deleteVariantIndex);
} }
setDeleteConfirmOpen(false); setDeleteConfirmOpen(false);
setDeleteVariantIndex(null); 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 InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
import { FileUpload } from '@/components/file-upload';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { index as productIndex } from '@/routes/admin/master/products'; 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 = { type Props = {
variant: { variant: {
@ -18,8 +18,8 @@ type Props = {
stock: number; stock: number;
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo_key: string | null; photo_keys: string[];
photo_url: string | null; photo_urls: string[];
prices: Array<{ type: string; price: number }>; prices: Array<{ type: string; price: number }>;
}; };
}; };
@ -41,7 +41,12 @@ export default function ProductVariantEdit({ variant }: Props) {
const [stock, setStock] = useState(variant.stock); const [stock, setStock] = useState(variant.stock);
const [rejectStock, setRejectStock] = useState(variant.reject_stock); const [rejectStock, setRejectStock] = useState(variant.reject_stock);
const [retailStock, setRetailStock] = useState(variant.retail_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 [uploading, setUploading] = useState(false);
const [prices, setPrices] = useState< const [prices, setPrices] = useState<
Array<{ type: string; price: number }> Array<{ type: string; price: number }>
@ -63,7 +68,7 @@ export default function ProductVariantEdit({ variant }: Props) {
stock: Number(stock), stock: Number(stock),
reject_stock: Number(rejectStock), reject_stock: Number(rejectStock),
retail_stock: Number(retailStock), retail_stock: Number(retailStock),
photo_key: photo, photo_keys: photos.map((p) => p.key),
prices: prices.map((p) => ({ prices: prices.map((p) => ({
type: p.type, type: p.type,
price: Number(p.price), price: Number(p.price),
@ -188,15 +193,15 @@ export default function ProductVariantEdit({ variant }: Props) {
<CardTitle>Foto Varian</CardTitle> <CardTitle>Foto Varian</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FileUpload <FileUploadMultiple
value={photo} value={photos}
onChange={setPhoto} onChange={setPhotos}
folder="product-variant" folder="product-variant"
existingUrl={variant.photo_url} maxItems={5}
onUploadingChange={setUploading} onUploadingChange={setUploading}
/> />
<InputError <InputError
message={errors.photo_key} message={errors.photo_keys}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@ -2,12 +2,6 @@ import { ArrowRightLeft, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal'; import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
Table, Table,
TableBody, TableBody,
@ -16,6 +10,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { Product, ProductVariant } from '../columns'; import type { Product, ProductVariant } from '../columns';
import { TransferStockDialog } from './transfer-stock-dialog'; import { TransferStockDialog } from './transfer-stock-dialog';
@ -31,25 +31,31 @@ function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num); 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); const [open, setOpen] = useState(false);
return ( return (
<> <>
<button <button
onClick={() => setOpen(true)} 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 <img
src={url} src={urls[0]}
alt={title} alt={title}
className="h-full w-full object-cover" 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> </button>
<ImagePreviewModal <ImagePreviewModal
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
src={url} src={urls[0]}
sources={urls}
title={title} title={title}
/> />
</> </>
@ -111,9 +117,9 @@ export function VariantSubRow({
{index + 1} {index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell>
{variant.photo_url ? ( {variant.photo_urls?.length > 0 ? (
<VariantPhotoPreview <VariantPhotoPreview
url={variant.photo_url} urls={variant.photo_urls}
title={variant.name} title={variant.name}
/> />
) : ( ) : (

View File

@ -35,7 +35,7 @@ function makeValidProductPayload(array $overrides = []): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/test-photo.jpg', 'photo_keys' => ['product-variant/test-photo.jpg'],
'prices' => [ 'prices' => [
['type' => 'distributor', 'price' => 10000], ['type' => 'distributor', 'price' => 10000],
['type' => 'agent', 'price' => 11000], ['type' => 'agent', 'price' => 11000],
@ -79,7 +79,7 @@ function makeValidSharedPricePayload(array $overrides = []): array
'stock' => 50, 'stock' => 50,
'reject_stock' => 5, 'reject_stock' => 5,
'retail_stock' => 10, 'retail_stock' => 10,
'photo_key' => 'product-variant/shared-photo.jpg', 'photo_keys' => ['product-variant/shared-photo.jpg'],
'prices' => [], 'prices' => [],
], ],
], ],
@ -378,7 +378,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 1, 'reject_stock' => 1,
'retail_stock' => 2, 'retail_stock' => 2,
'photo_key' => 'product-variant/v1.jpg', 'photo_keys' => ['product-variant/v1.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
], ],
[ [
@ -386,7 +386,7 @@ function allPriceTypes(): array
'stock' => 20, 'stock' => 20,
'reject_stock' => 2, 'reject_stock' => 2,
'retail_stock' => 4, '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()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()),
], ],
], ],
@ -408,7 +408,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_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()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()),
]], ]],
])); ]));
@ -430,7 +430,7 @@ function allPriceTypes(): array
'stock' => 1, 'stock' => 1,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_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()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()),
]], ]],
])); ]));
@ -448,7 +448,7 @@ function allPriceTypes(): array
'stock' => 0, 'stock' => 0,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/empty.jpg', 'photo_keys' => ['product-variant/empty.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -695,15 +695,15 @@ function allPriceTypes(): array
$response->assertSessionHasErrors('variants.0.retail_stock'); $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(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$payload = makeValidProductPayload(); $payload = makeValidProductPayload();
unset($payload['variants'][0]['photo_key']); unset($payload['variants'][0]['photo_keys']);
$response = $this->post(route('admin.master.products.store'), $payload); $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, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/existing.jpg', 'photo_keys' => ['product-variant/existing.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -995,7 +995,7 @@ function allPriceTypes(): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/v1.jpg', 'photo_keys' => ['product-variant/v1.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1028,7 +1028,7 @@ function allPriceTypes(): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/v1.jpg', 'photo_keys' => ['product-variant/v1.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1057,7 +1057,7 @@ function allPriceTypes(): array
'stock' => 42, 'stock' => 42,
'reject_stock' => 3, 'reject_stock' => 3,
'retail_stock' => 7, 'retail_stock' => 7,
'photo_key' => 'product-variant/new.jpg', 'photo_keys' => ['product-variant/new.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1087,7 +1087,7 @@ function allPriceTypes(): array
'stock' => 999, 'stock' => 999,
'reject_stock' => 50, 'reject_stock' => 50,
'retail_stock' => 75, 'retail_stock' => 75,
'photo_key' => 'product-variant/updated.jpg', 'photo_keys' => ['product-variant/updated.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
], ],
], ],
@ -1433,7 +1433,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/weird.jpg', 'photo_keys' => ['product-variant/weird.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1451,7 +1451,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/maxlen.jpg', 'photo_keys' => ['product-variant/maxlen.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1469,7 +1469,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/overmax.jpg', 'photo_keys' => ['product-variant/overmax.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1487,7 +1487,7 @@ function allPriceTypes(): array
'stock' => 2147483647, 'stock' => 2147483647,
'reject_stock' => 2147483647, 'reject_stock' => 2147483647,
'retail_stock' => 2147483647, 'retail_stock' => 2147483647,
'photo_key' => 'product-variant/huge.jpg', 'photo_keys' => ['product-variant/huge.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1505,7 +1505,7 @@ function allPriceTypes(): array
'stock' => 10, 'stock' => 10,
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/whitespace.jpg', 'photo_keys' => ['product-variant/whitespace.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1763,7 +1763,7 @@ function allPriceTypes(): array
'stock' => $variant->stock, 'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock, 'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_key' => 'product-variant/final.jpg', 'photo_keys' => ['product-variant/final.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]], ]],
])); ]));
@ -1824,7 +1824,7 @@ function allPriceTypes(): array
'stock' => $i * 10, 'stock' => $i * 10,
'reject_stock' => $i, 'reject_stock' => $i,
'retail_stock' => $i * 2, 'retail_stock' => $i * 2,
'photo_key' => "product-variant/v{$i}.jpg", 'photo_keys' => ["product-variant/v{$i}.jpg"],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]; ];
} }
@ -1955,7 +1955,7 @@ function allPriceTypes(): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/all-prices.jpg', 'photo_keys' => ['product-variant/all-prices.jpg'],
'prices' => $prices, 'prices' => $prices,
]], ]],
])); ]));
@ -1976,8 +1976,8 @@ function allPriceTypes(): array
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([ $response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'variants' => [ 'variants' => [
['name' => 'V1', 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_key' => 'v1.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_key' => 'v2.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, 'stock' => 50,
'reject_stock' => 5, 'reject_stock' => 5,
'retail_stock' => 10, 'retail_stock' => 10,
'photo_key' => 'product-variant/updated.jpg', 'photo_keys' => ['product-variant/updated.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]); ]);
@ -2098,7 +2098,7 @@ function allPriceTypes(): array
'stock' => 200, 'stock' => 200,
'reject_stock' => 15, 'reject_stock' => 15,
'retail_stock' => 25, 'retail_stock' => 25,
'photo_key' => 'product-variant/new-photo.jpg', 'photo_keys' => ['product-variant/new-photo.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]); ]);
@ -2132,7 +2132,7 @@ function allPriceTypes(): array
'stock' => $variant->stock, 'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock, 'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_key' => 'product-variant/test.jpg', 'photo_keys' => ['product-variant/test.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]); ]);
@ -2152,7 +2152,7 @@ function allPriceTypes(): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/test.jpg', 'photo_keys' => ['product-variant/test.jpg'],
'prices' => allPriceTypes(), 'prices' => allPriceTypes(),
]); ]);
@ -2171,7 +2171,7 @@ function allPriceTypes(): array
'stock' => 100, 'stock' => 100,
'reject_stock' => 10, 'reject_stock' => 10,
'retail_stock' => 20, 'retail_stock' => 20,
'photo_key' => 'product-variant/test.jpg', 'photo_keys' => ['product-variant/test.jpg'],
'prices' => [ 'prices' => [
['type' => 'retail', 'price' => 10000], ['type' => 'retail', 'price' => 10000],
], ],