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:
parent
f97e59bccf
commit
10db7bc5a2
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
156
resources/js/components/file-upload-multiple.tsx
Normal file
156
resources/js/components/file-upload-multiple.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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],
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user