- 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.
1333 lines
74 KiB
TypeScript
1333 lines
74 KiB
TypeScript
'use no memo';
|
|
|
|
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ClipboardPaste,
|
|
Copy,
|
|
Minus,
|
|
Plus,
|
|
ShoppingCart,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
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 { RupiahInput } from '@/components/rupiah-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 { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft';
|
|
import { UNITS } from '@/lib/constants';
|
|
import { formatNumber } from '@/lib/format';
|
|
import { loadPurchaseDraft } from '@/lib/purchase-draft';
|
|
import { getTemporaryUrl } from '@/lib/upload';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
|
|
import type { PurchaseCreateData } from './columns';
|
|
|
|
type VariantState = {
|
|
variant: string;
|
|
price: number;
|
|
stock: number;
|
|
photo: string | null;
|
|
photoUrl: string | null;
|
|
uploading: boolean;
|
|
};
|
|
|
|
type CartLine = {
|
|
key: string;
|
|
photoUrl: string | null;
|
|
title: string;
|
|
subtitle: string;
|
|
price: number;
|
|
quantity: number;
|
|
onAdjust: (delta: number) => void;
|
|
onSet: (value: number) => void;
|
|
onRemove: () => void;
|
|
};
|
|
|
|
type Props = {
|
|
data: PurchaseCreateData;
|
|
};
|
|
|
|
export default function PurchaseCreate({ data }: Props) {
|
|
const { suppliers, rawMaterials } = data;
|
|
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
|
const userId = auth.user?.id;
|
|
|
|
const draft = loadPurchaseDraft('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: 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 [supplierId, setSupplierId] = useState(draft?.supplierId ?? '');
|
|
const selectedSupplier =
|
|
suppliers.find((s) => String(s.id) === supplierId) ?? null;
|
|
const [discount, setDiscount] = useState(draft?.discount ?? 0);
|
|
const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0);
|
|
const [notes, setNotes] = useState(draft?.notes ?? '');
|
|
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
|
|
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
|
draft?.photo ? getTemporaryUrl(draft.photo) : null,
|
|
);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new');
|
|
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
|
draft?.selectedMaterialName ?? '',
|
|
);
|
|
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
|
Object.fromEntries(
|
|
Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [
|
|
Number(id),
|
|
qty,
|
|
]),
|
|
),
|
|
);
|
|
const [cartOpen, setCartOpen] = useState(false);
|
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
|
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
|
|
|
|
const draftData = useMemo(
|
|
() => ({
|
|
name,
|
|
unit,
|
|
supplierId,
|
|
discount,
|
|
shippingCost,
|
|
notes,
|
|
variants: variants.map((v) => ({
|
|
variant: v.variant,
|
|
price: v.price,
|
|
stock: v.stock,
|
|
photo: v.photo ?? undefined,
|
|
})),
|
|
mode,
|
|
selectedMaterialName,
|
|
quantities: Object.fromEntries(
|
|
Object.entries(quantities).map(([id, qty]) => [
|
|
String(id),
|
|
qty,
|
|
]),
|
|
),
|
|
photo: photo ?? undefined,
|
|
}),
|
|
[
|
|
name,
|
|
unit,
|
|
supplierId,
|
|
discount,
|
|
shippingCost,
|
|
notes,
|
|
variants,
|
|
mode,
|
|
selectedMaterialName,
|
|
quantities,
|
|
photo,
|
|
],
|
|
);
|
|
|
|
usePurchaseDraftSave('create', draftData, userId);
|
|
|
|
const variantsRef = useRef(variants);
|
|
variantsRef.current = variants;
|
|
|
|
const priceMap = useMemo(
|
|
() =>
|
|
new Map(
|
|
rawMaterials.flatMap((m) =>
|
|
m.raw_material_prices.map((p) => [p.id, p]),
|
|
),
|
|
),
|
|
[rawMaterials],
|
|
);
|
|
|
|
const materialByPriceId = useMemo(
|
|
() =>
|
|
new Map(
|
|
rawMaterials.flatMap((m) =>
|
|
m.raw_material_prices.map((p) => [
|
|
p.id,
|
|
{ name: m.name, unit: m.unit },
|
|
]),
|
|
),
|
|
),
|
|
[rawMaterials],
|
|
);
|
|
|
|
const newSubtotal = variants.reduce(
|
|
(sum, v) => sum + Number(v.price) * Number(v.stock),
|
|
0,
|
|
);
|
|
const existingSubtotal = Object.entries(quantities).reduce(
|
|
(sum, [priceId, quantity]) => {
|
|
const price = priceMap.get(Number(priceId));
|
|
|
|
return sum + (price ? price.price * quantity : 0);
|
|
},
|
|
0,
|
|
);
|
|
const subtotal = mode === 'existing' ? existingSubtotal : newSubtotal;
|
|
const total = subtotal - discount + shippingCost;
|
|
|
|
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 },
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
const selectedMaterial = useMemo(
|
|
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
|
|
[rawMaterials, selectedMaterialName],
|
|
);
|
|
|
|
const updateQuantity = useCallback((priceId: number, value: number) => {
|
|
setQuantities((prev) => ({
|
|
...prev,
|
|
[priceId]: Math.max(0, value),
|
|
}));
|
|
}, []);
|
|
|
|
const incrementQuantity = useCallback((priceId: number, amount: number) => {
|
|
setQuantities((prev) => ({
|
|
...prev,
|
|
[priceId]: Math.max(0, (prev[priceId] ?? 0) + amount),
|
|
}));
|
|
}, []);
|
|
|
|
const adjustVariantStock = useCallback((index: number, amount: number) => {
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
updated[index] = {
|
|
...updated[index],
|
|
stock: Math.max(0, Number(updated[index].stock) + amount),
|
|
};
|
|
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const cartItems: CartLine[] = (() => {
|
|
if (mode === 'existing') {
|
|
const lines: CartLine[] = [];
|
|
|
|
for (const [priceId, quantity] of Object.entries(quantities)) {
|
|
if (quantity <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const id = Number(priceId);
|
|
const price = priceMap.get(id);
|
|
const material = materialByPriceId.get(id);
|
|
|
|
if (price && material) {
|
|
lines.push({
|
|
key: `existing-${id}`,
|
|
photoUrl: price.photo_url,
|
|
title: `${material.name} — ${price.variant}`,
|
|
subtitle: `${formatCurrency(price.price)} / ${material.unit}`,
|
|
price: price.price,
|
|
quantity,
|
|
onAdjust: (delta) => incrementQuantity(id, delta),
|
|
onSet: (value) => updateQuantity(id, value),
|
|
onRemove: () => updateQuantity(id, 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
return lines.sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
|
|
return variants
|
|
.map((v, index): CartLine => ({
|
|
key: `new-${index}`,
|
|
photoUrl: v.photoUrl,
|
|
title: `${name || 'Bahan Baku Baru'} — ${v.variant || `Varian ${index + 1}`}`,
|
|
subtitle: `${formatCurrency(v.price)} / ${unit}`,
|
|
price: v.price,
|
|
quantity: v.stock,
|
|
onAdjust: (delta) => adjustVariantStock(index, delta),
|
|
onSet: (value) => updateVariant(index, 'stock', value),
|
|
onRemove: () => removeVariant(index),
|
|
}))
|
|
.filter((line) => line.quantity > 0);
|
|
})();
|
|
|
|
function formatQuantity(value: number): string {
|
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
|
}
|
|
|
|
function getPayload() {
|
|
const base = {
|
|
mode,
|
|
supplier_id: supplierId ? Number(supplierId) : null,
|
|
discount,
|
|
shipping_cost: shippingCost,
|
|
notes: notes || null,
|
|
photo_key: photo,
|
|
};
|
|
|
|
if (mode === 'existing') {
|
|
return {
|
|
...base,
|
|
existing_items: Object.entries(quantities)
|
|
.map(([priceId, quantity]) => ({
|
|
raw_material_price_id: Number(priceId),
|
|
quantity: Number(quantity),
|
|
unit_price: priceMap.get(Number(priceId))?.price ?? 0,
|
|
}))
|
|
.filter((item) => item.quantity > 0),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
name,
|
|
unit,
|
|
variants: variantsRef.current.map((v) => ({
|
|
variant: v.variant,
|
|
price: Number(v.price),
|
|
stock: Number(v.stock),
|
|
photo_key: v.photo,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Tambah Belanja" />
|
|
|
|
<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 Belanja
|
|
</h2>
|
|
<Button asChild variant="outline">
|
|
<Link href={purchaseIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form
|
|
action={store()}
|
|
transform={(data) => ({
|
|
...data,
|
|
...getPayload(),
|
|
})}
|
|
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">
|
|
<Tabs
|
|
value={mode}
|
|
onValueChange={(value) =>
|
|
setMode(value as 'new' | 'existing')
|
|
}
|
|
>
|
|
<TabsList>
|
|
<TabsTrigger value="new">
|
|
Baru
|
|
</TabsTrigger>
|
|
<TabsTrigger value="existing">
|
|
Lama
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent
|
|
value="new"
|
|
className="mt-0 space-y-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>
|
|
{variantIndex >
|
|
0 && (
|
|
<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>
|
|
<NumberInput
|
|
value={
|
|
variant.stock
|
|
}
|
|
onValueChange={(
|
|
val,
|
|
) =>
|
|
updateVariant(
|
|
variantIndex,
|
|
'stock',
|
|
val,
|
|
)
|
|
}
|
|
/>
|
|
<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>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="existing"
|
|
className="mt-0 space-y-6"
|
|
>
|
|
<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>
|
|
<InputError
|
|
message={
|
|
errors.existing_items
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{selectedMaterial && (
|
|
<div className="space-y-2">
|
|
{selectedMaterial.raw_material_prices.map(
|
|
(price) => (
|
|
<div
|
|
key={
|
|
price.id
|
|
}
|
|
className={
|
|
(quantities[
|
|
price
|
|
.id
|
|
] ??
|
|
0) >
|
|
0
|
|
? '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:{' '}
|
|
{formatQuantity(
|
|
Number(
|
|
price.stock,
|
|
),
|
|
)}{' '}
|
|
{
|
|
selectedMaterial.unit
|
|
}{' '}
|
|
·{' '}
|
|
{formatCurrency(
|
|
price.price,
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
disabled={
|
|
!(
|
|
quantities[
|
|
price
|
|
.id
|
|
] ??
|
|
0
|
|
)
|
|
}
|
|
onClick={() =>
|
|
incrementQuantity(
|
|
price.id,
|
|
-1,
|
|
)
|
|
}
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<NumberInput
|
|
className="w-24 text-center"
|
|
value={
|
|
quantities[
|
|
price
|
|
.id
|
|
] ??
|
|
0
|
|
}
|
|
onValueChange={(
|
|
val,
|
|
) =>
|
|
updateQuantity(
|
|
price.id,
|
|
val,
|
|
)
|
|
}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() =>
|
|
incrementQuantity(
|
|
price.id,
|
|
1,
|
|
)
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</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>
|
|
Supplier{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<Combobox
|
|
items={suppliers}
|
|
itemToStringLabel={(s) =>
|
|
s.name
|
|
}
|
|
value={selectedSupplier}
|
|
onValueChange={(value) =>
|
|
setSupplierId(
|
|
value
|
|
? String(value.id)
|
|
: '',
|
|
)
|
|
}
|
|
>
|
|
<ComboboxInput
|
|
placeholder="Cari supplier..."
|
|
className="w-full"
|
|
/>
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>
|
|
Tidak ada supplier
|
|
ditemukan.
|
|
</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(s) => (
|
|
<ComboboxItem
|
|
key={s.id}
|
|
value={s}
|
|
>
|
|
{s.name}
|
|
</ComboboxItem>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
<InputError
|
|
message={errors.supplier_id}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
Subtotal
|
|
</span>
|
|
<span className="font-medium">
|
|
{formatCurrency(subtotal)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
Diskon
|
|
</span>
|
|
<div className="w-36">
|
|
<RupiahInput
|
|
value={discount}
|
|
onValueChange={
|
|
setDiscount
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<InputError
|
|
message={errors.discount}
|
|
/>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
Ongkir
|
|
</span>
|
|
<div className="w-36">
|
|
<RupiahInput
|
|
value={shippingCost}
|
|
onValueChange={
|
|
setShippingCost
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<InputError
|
|
message={errors.shipping_cost}
|
|
/>
|
|
<div className="border-t pt-2">
|
|
<div className="flex items-center justify-between text-sm font-semibold">
|
|
<span>Total</span>
|
|
<span>
|
|
{formatCurrency(total)}
|
|
</span>
|
|
</div>
|
|
</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.notes}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Foto</Label>
|
|
<FileUpload
|
|
value={photo}
|
|
onChange={(key) => {
|
|
setPhoto(key);
|
|
const url = key
|
|
? `https://dstpabuaran.s3.ap-southeast-1.amazonaws.com/${key}`
|
|
: null;
|
|
setPhotoUrl(url);
|
|
}}
|
|
folder="purchase"
|
|
existingUrl={photoUrl}
|
|
onUploadingChange={setUploading}
|
|
/>
|
|
<InputError
|
|
message={errors.photo_key}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={
|
|
processing ||
|
|
uploading ||
|
|
variants.some(
|
|
(v) => v.uploading,
|
|
) ||
|
|
!supplierId ||
|
|
(mode === 'existing'
|
|
? Object.values(
|
|
quantities,
|
|
).every((q) => q <= 0)
|
|
: !name)
|
|
}
|
|
>
|
|
{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 belanja"
|
|
>
|
|
<ShoppingCart className="h-5 w-5" />
|
|
{cartItems.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">
|
|
{cartItems.length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
|
|
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
|
<SheetContent side="right" className="w-full sm:max-w-md">
|
|
<SheetHeader>
|
|
<SheetTitle>Keranjang Belanja</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
|
{cartItems.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Keranjang kosong.
|
|
</p>
|
|
) : (
|
|
cartItems.map((item) => (
|
|
<div
|
|
key={item.key}
|
|
className="space-y-3 rounded-lg border p-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-3">
|
|
{item.photoUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setPreviewKey(
|
|
item.key,
|
|
)
|
|
}
|
|
className="block h-10 w-10 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
|
>
|
|
<img
|
|
src={item.photoUrl}
|
|
alt={item.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</button>
|
|
) : (
|
|
<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>
|
|
<p className="font-medium">
|
|
{item.title}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{item.subtitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
setCartRemoveKey(item.key)
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon-sm"
|
|
disabled={
|
|
item.quantity <= 0
|
|
}
|
|
onClick={() =>
|
|
item.onAdjust(-1)
|
|
}
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<NumberInput
|
|
className="w-20 text-center"
|
|
value={item.quantity}
|
|
onValueChange={item.onSet}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
item.onAdjust(1)
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<span className="font-medium">
|
|
{formatCurrency(
|
|
item.price * item.quantity,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<div className="flex items-center justify-between border-t pt-4">
|
|
<span className="text-sm">Subtotal</span>
|
|
<span className="text-sm font-semibold">
|
|
{formatCurrency(subtotal)}
|
|
</span>
|
|
</div>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<ImagePreviewModal
|
|
open={previewKey !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPreviewKey(null);
|
|
}
|
|
}}
|
|
src={
|
|
cartItems.find((i) => i.key === previewKey)?.photoUrl ??
|
|
null
|
|
}
|
|
title={cartItems.find((i) => i.key === previewKey)?.title}
|
|
/>
|
|
|
|
<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);
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={cartRemoveKey !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCartRemoveKey(null);
|
|
}
|
|
}}
|
|
title="Hapus Item Keranjang"
|
|
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
|
|
confirmLabel="Hapus"
|
|
variant="destructive"
|
|
onConfirm={() => {
|
|
cartItems
|
|
.find((i) => i.key === cartRemoveKey)
|
|
?.onRemove();
|
|
setCartRemoveKey(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|