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.*.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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 {
|
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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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],
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user