553 lines
29 KiB
TypeScript
553 lines
29 KiB
TypeScript
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ClipboardPaste,
|
|
Copy,
|
|
Plus,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { ConfirmDialog } from '@/components/dialogs';
|
|
import { FileUpload } from '@/components/inputs';
|
|
import { InputError } from '@/components/ui';
|
|
import { RupiahInput } from '@/components/inputs';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
|
import { UNITS } from '@/lib/constants';
|
|
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
|
|
import { getTemporaryUrl } from '@/lib/upload';
|
|
import {
|
|
index as rawMaterialIndex,
|
|
store,
|
|
} from '@/routes/admin/master/raw-materials';
|
|
|
|
type VariantState = {
|
|
variant: string;
|
|
price: number;
|
|
stock: string;
|
|
photo: string | null;
|
|
photoUrl: string | null;
|
|
uploading: boolean;
|
|
};
|
|
|
|
export default function RawMaterialCreate() {
|
|
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
|
const userId = auth.user?.id;
|
|
|
|
const draft = loadRawMaterialDraft('create', userId);
|
|
|
|
const [name, setName] = useState(draft?.name ?? '');
|
|
const [unit, setUnit] = useState(draft?.unit ?? 'kg');
|
|
const [variants, setVariants] = useState<VariantState[]>(() => {
|
|
if (draft?.variants && draft.variants.length > 0) {
|
|
return draft.variants.map((v) => ({
|
|
variant: v.variant,
|
|
price: v.price,
|
|
stock: String(v.stock),
|
|
photo: v.photo ?? null,
|
|
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
|
|
uploading: false,
|
|
}));
|
|
}
|
|
|
|
return [
|
|
{
|
|
variant: '',
|
|
price: 0,
|
|
stock: '0',
|
|
photo: null,
|
|
photoUrl: null,
|
|
uploading: false,
|
|
},
|
|
];
|
|
});
|
|
|
|
const variantsRef = useRef(variants);
|
|
variantsRef.current = variants;
|
|
|
|
const draftData = {
|
|
name,
|
|
unit,
|
|
isActive: true,
|
|
variants: variants.map((v) => ({
|
|
variant: v.variant,
|
|
price: v.price,
|
|
stock: v.stock,
|
|
photo: v.photo ?? undefined,
|
|
})),
|
|
};
|
|
|
|
useRawMaterialDraftSave('create', draftData, userId);
|
|
|
|
const addVariant = useCallback(() => {
|
|
setVariants((prev) => [
|
|
...prev,
|
|
{
|
|
variant: '',
|
|
price: 0,
|
|
stock: '0',
|
|
photo: null,
|
|
photoUrl: null,
|
|
uploading: false,
|
|
},
|
|
]);
|
|
}, []);
|
|
|
|
const removeVariant = useCallback((index: number) => {
|
|
setVariants((prev) => prev.filter((_, i) => i !== index));
|
|
}, []);
|
|
|
|
const updateVariant = useCallback(
|
|
(index: number, field: keyof VariantState, value: unknown) => {
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
(updated[index] as Record<string, unknown>)[field] = value;
|
|
|
|
return updated;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
|
|
null,
|
|
);
|
|
|
|
const confirmRemoveVariant = useCallback((index: number) => {
|
|
setDeleteVariantIndex(index);
|
|
setDeleteConfirmOpen(true);
|
|
}, []);
|
|
|
|
const copyPrice = useCallback((variantIndex: number) => {
|
|
setVariants((prev) => {
|
|
const price = prev[variantIndex].price;
|
|
navigator.clipboard.writeText(String(price));
|
|
setCopiedIndex(variantIndex);
|
|
setTimeout(() => setCopiedIndex(null), 1500);
|
|
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const pastePrice = useCallback((variantIndex: number) => {
|
|
navigator.clipboard.readText().then((text) => {
|
|
try {
|
|
const price = Number(text);
|
|
|
|
if (!isNaN(price)) {
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
updated[variantIndex] = {
|
|
...updated[variantIndex],
|
|
price,
|
|
};
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
} catch {
|
|
// invalid clipboard data
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const applyToAll = useCallback((variantIndex: number) => {
|
|
setVariants((prev) => {
|
|
const sourcePrice = prev[variantIndex].price;
|
|
|
|
return prev.map((v, i) =>
|
|
i === variantIndex ? v : { ...v, price: sourcePrice },
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
function getPayload() {
|
|
return {
|
|
name,
|
|
unit,
|
|
is_active: true,
|
|
variants: variantsRef.current.map((v) => ({
|
|
variant: v.variant,
|
|
price: Number(v.price),
|
|
stock: parseFloat(String(v.stock)) || 0,
|
|
photo_key: v.photo,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Tambah Bahan Baku" />
|
|
|
|
<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 Bahan Baku
|
|
</h2>
|
|
<Button asChild variant="outline">
|
|
<Link href={rawMaterialIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form
|
|
action={store()}
|
|
transform={(data) => ({
|
|
...data,
|
|
...getPayload(),
|
|
})}
|
|
onError={() => {
|
|
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
|
|
}}
|
|
>
|
|
{({ errors, processing }) => (
|
|
<>
|
|
<div className="grid gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
Informasi Bahan Baku
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">
|
|
Nama Bahan Baku{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={name}
|
|
onChange={(e) =>
|
|
setName(e.target.value)
|
|
}
|
|
placeholder="Masukkan nama bahan baku"
|
|
/>
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Satuan{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<RadioGroup
|
|
name="unit"
|
|
value={unit}
|
|
onValueChange={setUnit}
|
|
className="flex flex-wrap gap-4"
|
|
>
|
|
{UNITS.map((u) => (
|
|
<div
|
|
key={u.value}
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<RadioGroupItem
|
|
value={u.value}
|
|
id={`unit-${u.value}`}
|
|
/>
|
|
<Label
|
|
htmlFor={`unit-${u.value}`}
|
|
className="font-normal"
|
|
>
|
|
{u.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
<InputError message={errors.unit} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Varian Bahan Baku</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{variants.map(
|
|
(variant, variantIndex) => (
|
|
<div
|
|
key={variantIndex}
|
|
className="space-y-4 rounded-lg border p-4"
|
|
>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<h4 className="font-medium">
|
|
Varian{' '}
|
|
{variantIndex + 1}
|
|
</h4>
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
onClick={() =>
|
|
copyPrice(
|
|
variantIndex,
|
|
)
|
|
}
|
|
>
|
|
{copiedIndex ===
|
|
variantIndex ? (
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
Salin Harga
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
onClick={() =>
|
|
pastePrice(
|
|
variantIndex,
|
|
)
|
|
}
|
|
>
|
|
<ClipboardPaste className="h-4 w-4" />
|
|
Tempel Harga
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
onClick={() =>
|
|
applyToAll(
|
|
variantIndex,
|
|
)
|
|
}
|
|
>
|
|
Terapkan ke
|
|
Semua
|
|
</Button>
|
|
{variants.length >
|
|
1 && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() =>
|
|
confirmRemoveVariant(
|
|
variantIndex,
|
|
)
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Nama Varian{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
value={
|
|
variant.variant
|
|
}
|
|
onChange={(e) =>
|
|
updateVariant(
|
|
variantIndex,
|
|
'variant',
|
|
e.target
|
|
.value,
|
|
)
|
|
}
|
|
placeholder="Contoh: Ukuran L, Warna Merah"
|
|
/>
|
|
<InputError
|
|
message={
|
|
errors[
|
|
`variants.${variantIndex}.variant`
|
|
]
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Harga{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<RupiahInput
|
|
value={
|
|
variant.price
|
|
}
|
|
onValueChange={(
|
|
val,
|
|
) =>
|
|
updateVariant(
|
|
variantIndex,
|
|
'price',
|
|
val,
|
|
)
|
|
}
|
|
/>
|
|
<InputError
|
|
message={
|
|
errors[
|
|
`variants.${variantIndex}.price`
|
|
]
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Stok{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={
|
|
variant.stock
|
|
}
|
|
onChange={(
|
|
e,
|
|
) =>
|
|
updateVariant(
|
|
variantIndex,
|
|
'stock',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
<InputError
|
|
message={
|
|
errors[
|
|
`variants.${variantIndex}.stock`
|
|
]
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Foto Varian{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<FileUpload
|
|
value={
|
|
variant.photo
|
|
}
|
|
onChange={(key) => {
|
|
updateVariant(
|
|
variantIndex,
|
|
'photo',
|
|
key,
|
|
);
|
|
updateVariant(
|
|
variantIndex,
|
|
'photoUrl',
|
|
key
|
|
? getTemporaryUrl(
|
|
key,
|
|
)
|
|
: null,
|
|
);
|
|
}}
|
|
folder="raw-material-variant"
|
|
existingUrl={
|
|
variant.photoUrl
|
|
}
|
|
onUploadingChange={(
|
|
uploading,
|
|
) =>
|
|
updateVariant(
|
|
variantIndex,
|
|
'uploading',
|
|
uploading,
|
|
)
|
|
}
|
|
/>
|
|
<InputError
|
|
message={
|
|
errors[
|
|
`variants.${variantIndex}.photo_key`
|
|
]
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
),
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={addVariant}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah Varian
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center gap-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={
|
|
processing ||
|
|
variants.some((v) => v.uploading)
|
|
}
|
|
>
|
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Form>
|
|
|
|
<ConfirmDialog
|
|
open={deleteConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteConfirmOpen(false);
|
|
setDeleteVariantIndex(null);
|
|
}
|
|
}}
|
|
title="Hapus Varian"
|
|
description="Apakah Anda yakin ingin menghapus varian ini?"
|
|
confirmLabel="Hapus"
|
|
onConfirm={() => {
|
|
if (deleteVariantIndex !== null) {
|
|
removeVariant(deleteVariantIndex);
|
|
}
|
|
|
|
setDeleteConfirmOpen(false);
|
|
setDeleteVariantIndex(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|