dstpabuaran.com/resources/js/pages/admin/manage/cutting/create.tsx

925 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, 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, RawMaterialVariant } 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;
photo_conversion_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,
photo_conversion_url: m.photo_conversion_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 [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
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 [comboSelectedItems, setComboSelectedItems] = useState<{ materialName: string; materialId: number; priceId: 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,
photo_conversion_url: m.photo_conversion_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(() => {
const map = new Map<number, RawMaterialVariant>();
for (const [, variants] of fetchedVariants) {
for (const p of variants) {
map.set(p.id, p);
}
}
return map;
}, [fetchedVariants]);
const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName],
);
const selectedMaterialVariants = useMemo(
() => (selectedMaterial ? fetchedVariants.get(selectedMaterial.id) ?? [] : []),
[selectedMaterial, fetchedVariants],
);
useEffect(() => {
if (!selectedMaterial) return;
if (fetchedVariants.has(selectedMaterial.id)) return;
setLoadingVariants(true);
fetch(`/admin/master/raw-materials/${selectedMaterial.id}/variants`)
.then((res) => res.json())
.then((data) => {
setFetchedVariants((prev) => {
const next = new Map(prev);
next.set(selectedMaterial.id, data.variants);
return next;
});
})
.catch(() => {
toast.error('Gagal memuat varian bahan baku.');
})
.finally(() => setLoadingVariants(false));
}, [selectedMaterial, fetchedVariants]);
const comboMaterialVariants = useMemo(
() => (comboMaterial ? fetchedVariants.get(comboMaterial.id) ?? [] : []),
[comboMaterial, fetchedVariants],
);
useEffect(() => {
if (!comboMaterial) return;
if (fetchedVariants.has(comboMaterial.id)) return;
setLoadingVariants(true);
fetch(`/admin/master/raw-materials/${comboMaterial.id}/variants`)
.then((res) => res.json())
.then((data) => {
setFetchedVariants((prev) => {
const next = new Map(prev);
next.set(comboMaterial.id, data.variants);
return next;
});
})
.catch(() => {
toast.error('Gagal memuat varian bahan baku.');
})
.finally(() => setLoadingVariants(false));
}, [comboMaterial, fetchedVariants]);
const groupedVariants = useMemo(() => {
if (!variantSearch) return [];
if (!selectedMaterial) return [];
const search = variantSearch.toLowerCase();
return [{
...selectedMaterial,
raw_material_prices: selectedMaterialVariants.filter(
(p) => p.variant.toLowerCase().includes(search),
),
}];
}, [selectedMaterial, selectedMaterialVariants, variantSearch]);
const addVariant = useCallback(
(rawMaterial: { id: number; name: string; unit: string }, priceId: number) => {
const variants = fetchedVariants.get(rawMaterial.id) ?? [];
const price = variants.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,
photo_conversion_url: price.photo_conversion_url,
},
];
});
},
[fetchedVariants],
);
const openComboDialog = useCallback((material: { id: number; name: string }, preSelectPriceId?: number) => {
setComboMaterialName(material.name);
if (preSelectPriceId) {
setComboSelectedItems([{ materialName: material.name, materialId: material.id, priceId: preSelectPriceId }]);
} else {
setComboSelectedItems([]);
}
setComboResult(0);
setComboDialogOpen(true);
}, []);
const toggleComboPrice = useCallback((priceId: number) => {
const material = comboMaterial;
if (!material) return;
setComboSelectedItems((prev) => {
const existing = prev.find((item) => item.priceId === priceId);
if (existing) {
return prev.filter((item) => item.priceId !== priceId);
}
return [...prev, { materialName: material.name, materialId: material.id, priceId }];
});
}, [comboMaterial]);
const confirmCombo = useCallback(() => {
if (comboSelectedItems.length < 2) {
return;
}
const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedItems.map((item) => {
const variants = fetchedVariants.get(item.materialId) ?? [];
const price = variants.find((p) => p.id === item.priceId);
return {
raw_material_price_id: item.priceId,
material_usage: '0',
material_result: 0,
combination_id: comboIndex,
variant: price?.variant ?? '',
material_name: item.materialName,
unit: rawMaterials.find((m) => m.id === item.materialId)?.unit ?? '',
photo_url: price?.photo_url ?? null,
photo_conversion_url: price?.photo_conversion_url ?? null,
};
});
setMaterials((prev) => [...prev, ...newMaterials]);
setCombinations((prev) => [
...prev,
{
material_result: comboResult,
},
]);
setComboDialogOpen(false);
setComboMaterialName('');
setComboSelectedItems([]);
setComboResult(0);
}, [comboSelectedItems, comboResult, rawMaterials, fetchedVariants, 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="min-w-0 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-x-hidden 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_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? 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, 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 && (
<div className="space-y-2">
{loadingVariants ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
<div className="space-y-2">
{selectedMaterialVariants.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_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? 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, 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_conversion_url ?? 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_conversion_url ?? 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>
{comboSelectedItems.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Varian Dipilih</Label>
<div className="space-y-1">
{comboSelectedItems.map((item) => {
const variants = fetchedVariants.get(item.materialId) ?? [];
const price = variants.find((pp) => pp.id === item.priceId);
const variantName = price?.variant ?? '';
const photoUrl = price?.photo_conversion_url ?? price?.photo_url ?? null;
return (
<div key={item.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">{item.materialName}</p>
</div>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(item.priceId)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
);
})}
</div>
</div>
)}
{comboMaterial && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
{loadingVariants ? (
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
Memuat varian...
</div>
) : comboMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
<div className="space-y-2">
{comboMaterialVariants.map((price) => {
const isSelected = comboSelectedItems.some((item) => item.priceId === 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_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? 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={comboSelectedItems.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>
</>
);
}