- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience. - Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
748 lines
44 KiB
TypeScript
748 lines
44 KiB
TypeScript
'use no memo';
|
||
|
||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||
import { FileUpload } from '@/components/file-upload';
|
||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||
import InputError from '@/components/input-error';
|
||
import { NumberInput } from '@/components/number-input';
|
||
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 { formatNumber } from '@/lib/format';
|
||
import { getTemporaryUrl } from '@/lib/upload';
|
||
import { formatCurrency } from '@/lib/utils';
|
||
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
|
||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
|
||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
import type { CuttingCreateData, CuttingForEdit } from './columns';
|
||
|
||
type MaterialState = {
|
||
id?: number;
|
||
raw_material_price_id: number;
|
||
material_usage: number;
|
||
material_result: number;
|
||
combination_id: number | null;
|
||
variant: string;
|
||
material_name: string;
|
||
unit: string;
|
||
photo_url: string | null;
|
||
};
|
||
|
||
type CombinationState = {
|
||
id?: number;
|
||
material_result: number;
|
||
};
|
||
|
||
type Props = {
|
||
cutting: CuttingForEdit;
|
||
data: CuttingCreateData;
|
||
};
|
||
|
||
export default function CuttingEdit({ cutting, data }: Props) {
|
||
const { rawMaterials } = data;
|
||
const { errors } = usePage().props as { errors: Record<string, string> };
|
||
|
||
const [materials, setMaterials] = useState<MaterialState[]>(() => {
|
||
const comboIdToIndex = new Map<number, number>();
|
||
cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i));
|
||
|
||
return cutting.materials.map((m) => {
|
||
const rawMaterial = rawMaterials.find((rm) =>
|
||
rm.raw_material_prices.some((p) => p.id === m.raw_material_price_id),
|
||
);
|
||
const price = rawMaterial?.raw_material_prices.find(
|
||
(p) => p.id === m.raw_material_price_id,
|
||
);
|
||
|
||
return {
|
||
id: m.id,
|
||
raw_material_price_id: m.raw_material_price_id,
|
||
material_usage: m.material_usage,
|
||
material_result: m.material_result ?? 0,
|
||
combination_id:
|
||
m.combination_id !== null
|
||
? (comboIdToIndex.get(m.combination_id) ?? null)
|
||
: null,
|
||
variant: price?.variant ?? m.variant ?? '',
|
||
material_name: rawMaterial?.name ?? '',
|
||
unit: rawMaterial?.unit ?? '',
|
||
photo_url: price?.photo_url ?? m.photo_url ?? null,
|
||
};
|
||
});
|
||
});
|
||
|
||
const [combinations, setCombinations] = useState<CombinationState[]>(
|
||
() =>
|
||
cutting.combinations.map((c) => ({
|
||
id: c.id,
|
||
material_result: c.material_result ?? 0,
|
||
})),
|
||
);
|
||
|
||
const [selectedMaterialName, setSelectedMaterialName] = useState('');
|
||
|
||
const [productName, setProductName] = useState(cutting.product_name);
|
||
const [sample, setSample] = useState(cutting.sample);
|
||
const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample);
|
||
const cuttingResult = sample + originalOutsideSample;
|
||
const [notes, setNotes] = useState(cutting.description ?? '');
|
||
const [photo, setPhoto] = useState<string | null>(cutting.photo_key);
|
||
const [photoUrl, setPhotoUrl] = useState<string | null>(cutting.photo_url);
|
||
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 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 addVariant = useCallback(
|
||
(priceId: number) => {
|
||
if (!selectedMaterial) {
|
||
return;
|
||
}
|
||
|
||
const price = selectedMaterial.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: selectedMaterial.name,
|
||
unit: selectedMaterial.unit,
|
||
photo_url: price.photo_url,
|
||
},
|
||
];
|
||
});
|
||
},
|
||
[selectedMaterial],
|
||
);
|
||
|
||
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 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 * m.material_usage : 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: m.material_usage,
|
||
material_result: m.material_result,
|
||
combination_index: m.combination_id,
|
||
})),
|
||
combinations: combinations.map((c) => ({
|
||
material_result: c.material_result,
|
||
})),
|
||
photo_key: photo,
|
||
};
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Head title="Edit 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">Edit Cutting</h2>
|
||
<Button asChild variant="outline">
|
||
<Link href={cuttingIndex.url()}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
Kembali
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
|
||
submittingRef.current = true;
|
||
}} onError={() => {
|
||
toast.error('Terjadi kesalahan saat menyimpan data. 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="space-y-4">
|
||
<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>
|
||
|
||
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||
<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={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
|
||
<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 shrink-0 items-center gap-1">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(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>
|
||
<FileUpload value={photo} onChange={(key) => {
|
||
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
|
||
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
|
||
<InputError message={errors.photo_key} />
|
||
</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>
|
||
<NumberInput
|
||
className="flex-1"
|
||
value={m.material_usage}
|
||
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
|
||
/>
|
||
</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 + m.material_usage, 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 className="sm:max-w-md">
|
||
<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>
|
||
</>
|
||
);
|
||
}
|