853 lines
51 KiB
TypeScript
853 lines
51 KiB
TypeScript
'use no memo';
|
||
|
||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
|
||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
import { ConfirmDialog } from '@/components/dialogs';
|
||
import { FileUploadMultiple } from '@/components/inputs';
|
||
import { ImagePreviewModal } from '@/components/dialogs';
|
||
import { InputError } from '@/components/ui';
|
||
import { NumberInput } from '@/components/inputs';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { useCuttingDraftSave } from '@/hooks/use-cutting-draft';
|
||
import { loadCuttingDraft } from '@/lib/cutting-draft';
|
||
import { formatNumber } from '@/lib/format';
|
||
import { getTemporaryUrl } from '@/lib/upload';
|
||
import { formatCurrency } from '@/lib/utils';
|
||
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
|
||
import type { CuttingCreateData } from './columns';
|
||
|
||
type MaterialState = {
|
||
raw_material_price_id: number;
|
||
material_usage: string;
|
||
material_result: number;
|
||
combination_id: number | null;
|
||
variant: string;
|
||
material_name: string;
|
||
unit: string;
|
||
photo_url: string | null;
|
||
};
|
||
|
||
type CombinationState = {
|
||
material_result: number;
|
||
};
|
||
|
||
type Props = {
|
||
rawMaterials: CuttingCreateData['rawMaterials'];
|
||
};
|
||
|
||
export default function CuttingCreate({ rawMaterials }: Props) {
|
||
const { auth, errors } = usePage().props as { auth: { user?: { id?: number } }; errors: Record<string, string> };
|
||
const userId = auth.user?.id;
|
||
|
||
const draft = loadCuttingDraft('create', userId);
|
||
|
||
const [materials, setMaterials] = useState<MaterialState[]>(() => {
|
||
if (draft?.materials && draft.materials.length > 0) {
|
||
return draft.materials.map((m) => ({
|
||
raw_material_price_id: m.raw_material_price_id,
|
||
material_usage: String(m.material_usage),
|
||
material_result: m.material_result ?? 0,
|
||
combination_id: m.combination_index ?? null,
|
||
variant: m.variant,
|
||
material_name: m.material_name,
|
||
unit: m.unit,
|
||
photo_url: m.photo_url,
|
||
}));
|
||
}
|
||
|
||
return [];
|
||
});
|
||
const [combinations, setCombinations] = useState<CombinationState[]>(() => {
|
||
if (draft?.combinations && draft.combinations.length > 0) {
|
||
return draft.combinations.map((c) => ({
|
||
material_result: c.material_result ?? 0,
|
||
}));
|
||
}
|
||
|
||
return [];
|
||
});
|
||
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
|
||
const [variantSearch, setVariantSearch] = useState('');
|
||
|
||
const [productName, setProductName] = useState(draft?.productName ?? '');
|
||
const [sample, setSample] = useState(draft?.sample ?? 0);
|
||
const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0);
|
||
const cuttingResult = sample + originalOutsideSample;
|
||
const [notes, setNotes] = useState(draft?.notes ?? '');
|
||
const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
|
||
if (draft?.photo_keys && draft.photo_keys.length > 0) {
|
||
return draft.photo_keys.map((key: string) => ({
|
||
key,
|
||
url: getTemporaryUrl(key),
|
||
}));
|
||
}
|
||
return [];
|
||
});
|
||
const [uploading, setUploading] = useState(false);
|
||
const submittingRef = useRef(false);
|
||
|
||
const [cartOpen, setCartOpen] = useState(false);
|
||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||
const [deleteMaterialIndex, setDeleteMaterialIndex] = useState<number | null>(null);
|
||
|
||
const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false);
|
||
const [cartDeleteIndex, setCartDeleteIndex] = useState<number | null>(null);
|
||
const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false);
|
||
const [comboDeleteIndex, setComboDeleteIndex] = useState<number | null>(null);
|
||
|
||
const [comboDialogOpen, setComboDialogOpen] = useState(false);
|
||
const [comboMaterialName, setComboMaterialName] = useState('');
|
||
const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState<number[]>([]);
|
||
const [comboResult, setComboResult] = useState(0);
|
||
|
||
const comboMaterial = useMemo(
|
||
() => rawMaterials.find((m) => m.name === comboMaterialName) ?? null,
|
||
[rawMaterials, comboMaterialName],
|
||
);
|
||
|
||
const draftData = useMemo(
|
||
() => ({
|
||
productName,
|
||
sample,
|
||
originalOutsideSample,
|
||
notes,
|
||
materials: materials.map((m) => ({
|
||
raw_material_price_id: m.raw_material_price_id,
|
||
material_usage: m.material_usage,
|
||
material_result: m.material_result,
|
||
combination_index: m.combination_id,
|
||
variant: m.variant,
|
||
material_name: m.material_name,
|
||
unit: m.unit,
|
||
photo_url: m.photo_url,
|
||
})),
|
||
combinations: combinations.map((c) => ({
|
||
material_result: c.material_result,
|
||
})),
|
||
selectedMaterialName,
|
||
photo_keys: photos.map((p) => p.key),
|
||
}),
|
||
[productName, sample, originalOutsideSample, notes, materials, combinations, selectedMaterialName, photos],
|
||
);
|
||
|
||
useCuttingDraftSave('create', draftData, userId);
|
||
|
||
const materialsRef = useRef(materials);
|
||
materialsRef.current = materials;
|
||
|
||
const priceMap = useMemo(
|
||
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
|
||
[rawMaterials],
|
||
);
|
||
|
||
const selectedMaterial = useMemo(
|
||
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
|
||
[rawMaterials, selectedMaterialName],
|
||
);
|
||
|
||
const groupedVariants = useMemo(() => {
|
||
if (!variantSearch) return [];
|
||
const search = variantSearch.toLowerCase();
|
||
return rawMaterials
|
||
.map((rm) => ({
|
||
...rm,
|
||
raw_material_prices: rm.raw_material_prices.filter(
|
||
(p) => p.variant.toLowerCase().includes(search),
|
||
),
|
||
}))
|
||
.filter((rm) => rm.raw_material_prices.length > 0);
|
||
}, [rawMaterials, variantSearch]);
|
||
|
||
const addVariant = useCallback(
|
||
(rawMaterial: (typeof rawMaterials)[number], priceId: number) => {
|
||
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
|
||
|
||
if (!price) {
|
||
return;
|
||
}
|
||
|
||
setMaterials((prev) => {
|
||
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) {
|
||
return prev;
|
||
}
|
||
|
||
return [
|
||
...prev,
|
||
{
|
||
raw_material_price_id: price.id,
|
||
material_usage: '0',
|
||
material_result: 0,
|
||
combination_id: null,
|
||
variant: price.variant,
|
||
material_name: rawMaterial.name,
|
||
unit: rawMaterial.unit,
|
||
photo_url: price.photo_url,
|
||
},
|
||
];
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
|
||
setComboMaterialName(materialName);
|
||
setComboSelectedPriceIds(preSelectPriceId ? [preSelectPriceId] : []);
|
||
setComboResult(0);
|
||
setComboDialogOpen(true);
|
||
}, []);
|
||
|
||
const toggleComboPrice = useCallback((priceId: number) => {
|
||
setComboSelectedPriceIds((prev) =>
|
||
prev.includes(priceId) ? prev.filter((id) => id !== priceId) : [...prev, priceId],
|
||
);
|
||
}, []);
|
||
|
||
const confirmCombo = useCallback(() => {
|
||
if (comboSelectedPriceIds.length < 2) {
|
||
return;
|
||
}
|
||
|
||
const comboIndex = combinations.length;
|
||
|
||
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
|
||
let foundMaterial: typeof rawMaterials[number] | undefined;
|
||
let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined;
|
||
|
||
for (const rm of rawMaterials) {
|
||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||
|
||
if (p) {
|
||
foundMaterial = rm;
|
||
foundPrice = p;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
raw_material_price_id: priceId,
|
||
material_usage: '0',
|
||
material_result: 0,
|
||
combination_id: comboIndex,
|
||
variant: foundPrice?.variant ?? '',
|
||
material_name: foundMaterial?.name ?? '',
|
||
unit: foundMaterial?.unit ?? '',
|
||
photo_url: foundPrice?.photo_url ?? null,
|
||
};
|
||
});
|
||
|
||
setMaterials((prev) => [...prev, ...newMaterials]);
|
||
setCombinations((prev) => [
|
||
...prev,
|
||
{
|
||
material_result: comboResult,
|
||
},
|
||
]);
|
||
|
||
setComboDialogOpen(false);
|
||
setComboMaterialName('');
|
||
setComboSelectedPriceIds([]);
|
||
setComboResult(0);
|
||
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
|
||
|
||
const removeMaterial = useCallback((index: number) => {
|
||
setDeleteMaterialIndex(index);
|
||
setDeleteConfirmOpen(true);
|
||
}, []);
|
||
|
||
const updateMaterial = useCallback(
|
||
(index: number, field: keyof MaterialState, value: unknown) => {
|
||
setMaterials((prev) => {
|
||
const updated = [...prev];
|
||
(updated[index] as Record<string, unknown>)[field] = value;
|
||
|
||
return updated;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const updateCombinationResult = useCallback((comboIndex: number, value: number) => {
|
||
setCombinations((prev) => prev.map((c, i) => (i === comboIndex ? { ...c, material_result: value } : c)));
|
||
}, []);
|
||
|
||
const totalMaterialCost = useMemo(() => {
|
||
return materials.reduce((sum, m) => {
|
||
const price = priceMap.get(m.raw_material_price_id);
|
||
|
||
return sum + (price ? price.price * (parseFloat(String(m.material_usage)) || 0) : 0);
|
||
}, 0);
|
||
}, [materials, priceMap]);
|
||
|
||
const totalCost = totalMaterialCost;
|
||
const costPerUnit = cuttingResult > 0 ? Math.floor(totalCost / cuttingResult) : 0;
|
||
|
||
function formatQuantity(value: number): string {
|
||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||
}
|
||
|
||
function getPayload() {
|
||
return {
|
||
description: notes || null,
|
||
product_name: productName || null,
|
||
sample: sample,
|
||
original_outside_sample: originalOutsideSample,
|
||
cutting_result: cuttingResult,
|
||
materials: materialsRef.current.map((m) => ({
|
||
raw_material_price_id: m.raw_material_price_id,
|
||
material_usage: parseFloat(String(m.material_usage)) || 0,
|
||
material_result: m.material_result,
|
||
combination_index: m.combination_id,
|
||
})),
|
||
combinations: combinations.map((c) => ({
|
||
material_result: c.material_result,
|
||
})),
|
||
photo_keys: photos.map((p) => p.key),
|
||
};
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Head title="Tambah Cutting" />
|
||
|
||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-2xl font-semibold tracking-tight">Tambah Cutting</h2>
|
||
<Button asChild variant="outline">
|
||
<Link href={cuttingIndex.url()}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
Kembali
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
|
||
submittingRef.current = true;
|
||
}} onError={() => {
|
||
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
|
||
}}>
|
||
{({ errors, processing }) => (
|
||
<div className="grid gap-6 md:grid-cols-3">
|
||
<div className="space-y-6 md:col-span-2">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-y-auto">
|
||
<div className="grid gap-2">
|
||
<Label>
|
||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||
</Label>
|
||
<Combobox
|
||
items={rawMaterials}
|
||
itemToStringLabel={(m) => m.name}
|
||
value={selectedMaterial}
|
||
onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')}
|
||
>
|
||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(m) => (
|
||
<ComboboxItem key={m.id} value={m}>
|
||
{m.name} ({m.unit})
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label className="text-sm font-medium">Cari Varian</Label>
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Ketik nama varian..."
|
||
value={variantSearch}
|
||
onChange={(e) => setVariantSearch(e.target.value)}
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{variantSearch && groupedVariants.length > 0 && (
|
||
<div className="space-y-4">
|
||
{groupedVariants.map((rm) => (
|
||
<div key={rm.id} className="space-y-2">
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
|
||
<div className="space-y-2">
|
||
{rm.raw_material_prices.map((price) => {
|
||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
||
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
|
||
|
||
return (
|
||
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
{price.photo_url ? (
|
||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||
) : (
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="truncate font-medium">{price.variant}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
Stok: {formatNumber(Number(price.stock))} {rm.unit} · {formatCurrency(price.price)}
|
||
{addedCount > 0 && ` · ×${addedCount}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(rm, price.id)}>
|
||
<Plus className="h-4 w-4" />
|
||
Tambah
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(rm.name, price.id)}>
|
||
<Layers className="h-4 w-4" />
|
||
Kombinasi
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{variantSearch && groupedVariants.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
|
||
)}
|
||
|
||
{!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="space-y-2">
|
||
{selectedMaterial.raw_material_prices.map((price) => {
|
||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
||
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
|
||
|
||
return (
|
||
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
{price.photo_url ? (
|
||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||
) : (
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="truncate font-medium">{price.variant}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
|
||
{addedCount > 0 && ` · ×${addedCount}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}>
|
||
<Plus className="h-4 w-4" />
|
||
Tambah
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
|
||
<Layers className="h-4 w-4" />
|
||
Kombinasi
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="space-y-6 md:col-span-1">
|
||
<Card className="sticky top-6">
|
||
<CardHeader>
|
||
<CardTitle>Ringkasan</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="product_name">
|
||
Nama Produk <span className="text-destructive">*</span>
|
||
</Label>
|
||
<Input id="product_name" name="product_name" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Masukkan nama produk" />
|
||
<InputError message={errors.product_name} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="sample">
|
||
Sample <span className="text-destructive">*</span>
|
||
</Label>
|
||
<NumberInput id="sample" value={sample} onValueChange={setSample} />
|
||
<InputError message={errors.sample} />
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="original_outside_sample">
|
||
Diluar Sample <span className="text-destructive">*</span>
|
||
</Label>
|
||
<NumberInput id="original_outside_sample" value={originalOutsideSample} onValueChange={setOriginalOutsideSample} />
|
||
<InputError message={errors.original_outside_sample} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="cutting_result">Hasil</Label>
|
||
<NumberInput id="cutting_result" value={cuttingResult} disabled />
|
||
<InputError message={errors.cutting_result} />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="border-t pt-2">
|
||
<div className="flex items-center justify-between text-sm font-semibold">
|
||
<span>Total</span>
|
||
<span>{formatCurrency(totalCost)}</span>
|
||
</div>
|
||
</div>
|
||
{cuttingResult > 0 && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-muted-foreground">Biaya Per Produk</span>
|
||
<span className="font-medium">{formatCurrency(costPerUnit)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="notes">Keterangan</Label>
|
||
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Masukkan keterangan" maxLength={100} />
|
||
<InputError message={errors.description} />
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label>Foto</Label>
|
||
<FileUploadMultiple
|
||
value={photos}
|
||
onChange={setPhotos}
|
||
folder="cutting"
|
||
maxItems={5}
|
||
onUploadingChange={setUploading}
|
||
/>
|
||
<InputError message={errors.photo_keys as string} />
|
||
</div>
|
||
|
||
<Button type="submit" className="w-full" disabled={processing}>
|
||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Form>
|
||
|
||
<Button type="button" onClick={() => setCartOpen(true)} className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg" size="icon" aria-label="Buka keranjang cutting">
|
||
<ShoppingCart className="h-5 w-5" />
|
||
{materials.length > 0 && (
|
||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
|
||
{materials.length}
|
||
</span>
|
||
)}
|
||
</Button>
|
||
|
||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||
<SheetHeader>
|
||
<SheetTitle>Keranjang Cutting</SheetTitle>
|
||
</SheetHeader>
|
||
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
||
{materials.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Keranjang kosong.</p>
|
||
) : (
|
||
(() => {
|
||
const groups: { comboIndex: number | null; items: { m: MaterialState; index: number }[] }[] = [];
|
||
const comboMap = new Map<number, { m: MaterialState; index: number }[]>();
|
||
const singleItems: { m: MaterialState; index: number }[] = [];
|
||
|
||
materials.forEach((m, i) => {
|
||
if (m.combination_id !== null) {
|
||
if (!comboMap.has(m.combination_id)) {
|
||
comboMap.set(m.combination_id, []);
|
||
}
|
||
|
||
comboMap.get(m.combination_id)!.push({ m, index: i });
|
||
} else {
|
||
singleItems.push({ m, index: i });
|
||
}
|
||
});
|
||
|
||
comboMap.forEach((items, comboIdx) => groups.push({ comboIndex: comboIdx, items }));
|
||
singleItems.forEach((item) => groups.push({ comboIndex: null, items: [item] }));
|
||
|
||
return groups.map((group, gi) => (
|
||
<div key={gi} className="space-y-2 rounded-lg border p-3">
|
||
{group.comboIndex !== null && (
|
||
<>
|
||
<div className="flex items-center justify-between border-b pb-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
|
||
<span className="text-xs text-muted-foreground">·</span>
|
||
<Label className="text-xs whitespace-nowrap">Hasil <span className="text-destructive">*</span></Label>
|
||
<NumberInput
|
||
className="w-20"
|
||
value={combinations[group.comboIndex]?.material_result ?? 0}
|
||
onValueChange={(val) => updateCombinationResult(group.comboIndex!, val)}
|
||
/>
|
||
</div>
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||
setComboDeleteIndex(group.comboIndex);
|
||
setComboDeleteConfirmOpen(true);
|
||
}}>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
<InputError message={errors[`combinations.${group.comboIndex}.material_result` as keyof typeof errors]} />
|
||
</>
|
||
)}
|
||
{group.items.map(({ m, index }) => {
|
||
const price = priceMap.get(m.raw_material_price_id);
|
||
const cartKey = `material-${index}`;
|
||
|
||
return (
|
||
<div key={cartKey} className="space-y-2">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex items-center gap-3">
|
||
{m.photo_url ? (
|
||
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
|
||
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
|
||
</button>
|
||
) : (
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||
)}
|
||
<div>
|
||
<p className="text-sm font-medium">{m.material_name}</p>
|
||
<p className="text-xs text-muted-foreground">{m.variant}{price ? ` · ${formatNumber(Number(price.stock))} ${m.unit}` : ''}</p>
|
||
</div>
|
||
</div>
|
||
{group.comboIndex === null && (
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||
setCartDeleteIndex(index);
|
||
setCartDeleteConfirmOpen(true);
|
||
}}>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
className="flex-1"
|
||
value={m.material_usage}
|
||
onChange={(e) => updateMaterial(index, 'material_usage', e.target.value)}
|
||
/>
|
||
</div>
|
||
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
|
||
{group.comboIndex === null && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
|
||
<NumberInput
|
||
className="flex-1"
|
||
value={m.material_result}
|
||
onValueChange={(val) => updateMaterial(index, 'material_result', val)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{group.comboIndex === null && <InputError message={errors[`materials.${index}.material_result` as keyof typeof errors]} />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
));
|
||
})()
|
||
)}
|
||
</div>
|
||
<SheetFooter>
|
||
<div className="flex items-center justify-between border-t pt-4">
|
||
<span className="text-sm">Total Pemakaian</span>
|
||
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + (parseFloat(String(m.material_usage)) || 0), 0))}</span>
|
||
</div>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
<ImagePreviewModal
|
||
open={previewKey !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) {
|
||
setPreviewKey(null);
|
||
}
|
||
}}
|
||
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
|
||
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name} — ${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
|
||
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
|
||
/>
|
||
|
||
{comboDialogOpen && (
|
||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setComboDialogOpen(false)} />
|
||
)}
|
||
|
||
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Tambah Kombinasi</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
<div className="grid gap-2">
|
||
<Label>
|
||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||
</Label>
|
||
<Combobox
|
||
items={rawMaterials}
|
||
itemToStringLabel={(m) => m.name}
|
||
value={comboMaterial}
|
||
onValueChange={(value) => setComboMaterialName(value?.name ?? '')}
|
||
>
|
||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(m) => (
|
||
<ComboboxItem key={m.id} value={m}>
|
||
{m.name} ({m.unit})
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
</div>
|
||
|
||
{comboSelectedPriceIds.length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Varian Dipilih</Label>
|
||
<div className="space-y-1">
|
||
{comboSelectedPriceIds.map((priceId) => {
|
||
let variantName = '';
|
||
let materialName = '';
|
||
let photoUrl: string | null = null;
|
||
|
||
for (const rm of rawMaterials) {
|
||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||
|
||
if (p) {
|
||
variantName = p.variant;
|
||
materialName = rm.name;
|
||
photoUrl = p.photo_url;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
{photoUrl ? (
|
||
<img src={photoUrl} alt={variantName} className="h-8 w-8 shrink-0 rounded-md object-cover" />
|
||
) : (
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium truncate">{variantName}</p>
|
||
<p className="text-xs text-muted-foreground">{materialName}</p>
|
||
</div>
|
||
</div>
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(priceId)}>
|
||
<Trash2 className="h-3 w-3 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||
<div className="space-y-2">
|
||
{comboMaterial.raw_material_prices.map((price) => {
|
||
const isSelected = comboSelectedPriceIds.includes(price.id);
|
||
|
||
return (
|
||
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
{price.photo_url ? (
|
||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||
) : (
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="truncate font-medium">{price.variant}</p>
|
||
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
|
||
</div>
|
||
</div>
|
||
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
|
||
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setComboDialogOpen(false)}>Batal</Button>
|
||
<Button type="button" onClick={confirmCombo} disabled={comboSelectedPriceIds.length < 2}>
|
||
Konfirmasi
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => {
|
||
if (!open) {
|
||
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
|
||
}
|
||
}} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => {
|
||
if (deleteMaterialIndex !== null) {
|
||
setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex));
|
||
}
|
||
|
||
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
|
||
}} />
|
||
|
||
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => {
|
||
if (!open) {
|
||
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
|
||
}
|
||
}} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
|
||
if (cartDeleteIndex !== null) {
|
||
setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex));
|
||
}
|
||
|
||
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
|
||
}} />
|
||
|
||
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => {
|
||
if (!open) {
|
||
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
|
||
}
|
||
}} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
|
||
if (comboDeleteIndex !== null) {
|
||
setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex));
|
||
}
|
||
|
||
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
|
||
}} />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|