925 lines
55 KiB
TypeScript
925 lines
55 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, 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>
|
||
</>
|
||
);
|
||
}
|