dstpabuaran.com/resources/js/pages/admin/manage/purchase/edit.tsx
Yoga Pangestu 50b2be68ac Add error handling with toast notifications for form submissions
- 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.
2026-08-07 08:50:11 +07:00

742 lines
40 KiB
TypeScript

'use no memo';
import { Form, Head, Link } from '@inertiajs/react';
import {
ArrowLeft,
Minus,
Plus,
ShoppingCart,
Trash2,
} from 'lucide-react';
import { useCallback, useMemo, 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 { 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 { formatCurrency } from '@/lib/utils';
import {
index as purchaseIndex,
update,
} from '@/routes/admin/manage/purchases';
import type { PurchaseCreateData, PurchaseForEdit } from './columns';
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 = {
purchase: PurchaseForEdit;
data: PurchaseCreateData;
};
export default function PurchaseEdit({ purchase, data }: Props) {
const { suppliers, rawMaterials } = data;
const [supplierId, setSupplierId] = useState(String(purchase.supplier_id));
const selectedSupplier =
suppliers.find((s) => String(s.id) === supplierId) ?? null;
const [discount, setDiscount] = useState(purchase.discount);
const [shippingCost, setShippingCost] = useState(purchase.shipping_cost);
const [notes, setNotes] = useState(purchase.notes ?? '');
const [photo, setPhoto] = useState<string | null>(purchase.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
const [uploading, setUploading] = useState(false);
const [selectedMaterialName, setSelectedMaterialName] = useState(
purchase.existing_material_name ?? '',
);
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
Object.fromEntries(
Object.entries(purchase.existing_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 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 subtotal = Object.entries(quantities).reduce(
(sum, [priceId, quantity]) => {
const price = priceMap.get(Number(priceId));
return sum + (price ? price.price * quantity : 0);
},
0,
);
const total = subtotal - discount + shippingCost;
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 cartItems: CartLine[] = (() => {
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));
})();
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
return {
mode: 'existing',
supplier_id: supplierId ? Number(supplierId) : null,
discount,
shipping_cost: shippingCost,
notes: notes || null,
photo_key: photo,
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 (
<>
<Head title="Edit 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">
Edit Belanja
</h2>
<Button asChild variant="outline">
<Link href={purchaseIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
<Form
action={update(purchase.id)}
method="put"
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">
<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"
disabled
/>
<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>
</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="edit-notes">
Keterangan
</Label>
<Textarea
id="edit-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 ||
!supplierId ||
Object.values(
quantities,
).every((q) => q <= 0)
}
>
{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={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>
</>
);
}