dstpabuaran.com/resources/js/pages/admin/manage/invoice/edit.tsx
Yoga Pangestu 2a0b99c599 feat: implement invoice management features including index, create, update, delete, and print functionalities
- Added InvoiceIndex component for displaying a list of invoices with pagination and search capabilities.
- Created InvoiceShareButton component for sharing invoice details via WhatsApp.
- Developed InvoicePrint component for printing invoice details.
- Defined routes for invoice management including share and print functionalities.
- Implemented InvoiceTest to cover authentication, authorization, and CRUD operations for invoices.
2026-08-22 15:13:28 +07:00

557 lines
28 KiB
TypeScript

import { ConfirmDialog } from '@/components/dialogs';
import { DatePicker, PhoneNumberInput, RupiahInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { formatCurrency } from '@/lib/utils';
import {
index as invoiceIndex,
print,
update,
} from '@/routes/admin/manage/invoices';
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Plus, Printer, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { InvoiceForEdit } from './columns';
type ItemRow = {
name: string;
quantity: string;
price: number;
};
type Props = {
invoice: InvoiceForEdit;
statusOptions: { value: string; label: string }[];
};
function toDateInputValue(date: Date | undefined) {
return date ? date.toISOString().split('T')[0] : '';
}
function parseDateValue(value: string | null) {
if (!value) {
return undefined;
}
return new Date(value);
}
function emptyItem(): ItemRow {
return { name: '', quantity: '1', price: 0 };
}
export default function InvoiceEdit({ invoice, statusOptions }: Props) {
const [invoiceNumber, setInvoiceNumber] = useState(
invoice.invoice_number,
);
const [customerName, setCustomerName] = useState(invoice.customer_name);
const [date, setDate] = useState<Date | undefined>(
parseDateValue(invoice.date),
);
const [dueDate, setDueDate] = useState<Date | undefined>(
parseDateValue(invoice.due_date),
);
const [status, setStatus] = useState<string>(invoice.status);
const [description, setDescription] = useState(
invoice.description ?? '',
);
const [items, setItems] = useState<ItemRow[]>(() =>
invoice.items.length > 0
? invoice.items.map((item) => ({
name: item.name,
quantity: String(item.quantity),
price: item.price,
}))
: [emptyItem()],
);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteItemIndex, setDeleteItemIndex] = useState<number | null>(
null,
);
const addItem = useCallback(() => {
setItems((prev) => [...prev, emptyItem()]);
}, []);
const removeItem = useCallback((index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
}, []);
const confirmRemoveItem = useCallback(
(index: number) => {
if (items.length <= 1) {
removeItem(index);
return;
}
setDeleteItemIndex(index);
setDeleteConfirmOpen(true);
},
[items.length, removeItem],
);
const updateItem = useCallback(
(index: number, field: keyof ItemRow, value: string | number) => {
setItems((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const total = items.reduce(
(sum, item) => sum + (parseInt(item.quantity, 10) || 0) * item.price,
0,
);
function getPayload() {
return {
invoice_number: invoiceNumber,
customer_name: customerName,
date: toDateInputValue(date),
due_date: dueDate ? toDateInputValue(dueDate) : null,
status,
description: description || null,
items: items.map((item) => ({
name: item.name,
quantity: parseInt(item.quantity, 10) || 0,
price: item.price,
})),
};
}
return (
<>
<Head title="Edit Invoice" />
<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 Invoice
</h2>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={print.url(invoice.id)}>
<Printer className="h-4 w-4" />
Cetak PDF
</Link>
</Button>
<Button asChild variant="outline">
<Link href={invoiceIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
</div>
<Form
action={update(invoice.id)}
transform={(data) => ({
...data,
...getPayload(),
})}
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="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Informasi Invoice</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="invoice_number">
Nomor Invoice{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="invoice_number"
value={invoiceNumber}
onChange={(e) =>
setInvoiceNumber(
e.target.value,
)
}
placeholder="Masukkan nomor invoice"
/>
<InputError
message={errors.invoice_number}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer_name">
Nama Pelanggan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="customer_name"
value={customerName}
onChange={(e) =>
setCustomerName(
e.target.value,
)
}
placeholder="Masukkan nama pelanggan"
/>
<InputError
message={errors.customer_name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer_phone">
No. Telepon Pelanggan
</Label>
<PhoneNumberInput
name="customer_phone"
defaultValue={
invoice.customer_phone
}
placeholder="0812 3456 7890"
/>
<InputError
message={
errors.customer_phone
}
/>
</div>
<div className="grid gap-2 md:col-span-3">
<Label htmlFor="customer_address">
Alamat Pelanggan
</Label>
<Textarea
id="customer_address"
name="customer_address"
defaultValue={
invoice.customer_address ??
''
}
placeholder="Masukkan alamat pelanggan"
/>
<InputError
message={
errors.customer_address
}
/>
</div>
<div className="grid grid-cols-2 gap-4 md:col-span-3">
<div className="grid gap-2">
<Label htmlFor="date">
Tanggal{' '}
<span className="text-destructive">
*
</span>
</Label>
<DatePicker
value={date}
onChange={setDate}
placeholder="Pilih tanggal"
/>
<InputError
message={errors.date}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="due_date">
Jatuh Tempo
</Label>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Pilih jatuh tempo"
/>
<InputError
message={errors.due_date}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Item Invoice</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{items.map((item, index) => (
<div
key={index}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<h4 className="font-medium">
Item {index + 1}
</h4>
{items.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
confirmRemoveItem(
index,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2 md:col-span-1">
<Label>
Nama Item{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={item.name}
onChange={(e) =>
updateItem(
index,
'name',
e.target
.value,
)
}
placeholder="Contoh: Jasa Cutting"
/>
<InputError
message={
errors[
`items.${index}.name`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="text"
inputMode="numeric"
value={
item.quantity
}
onChange={(e) =>
updateItem(
index,
'quantity',
e.target
.value,
)
}
/>
<InputError
message={
errors[
`items.${index}.quantity`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Harga Satuan{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={item.price}
onValueChange={(
val,
) =>
updateItem(
index,
'price',
val,
)
}
/>
<InputError
message={
errors[
`items.${index}.price`
]
}
/>
</div>
</div>
<div className="text-right text-sm text-muted-foreground">
Subtotal:{' '}
<span className="font-medium text-foreground">
{formatCurrency(
(parseInt(
item.quantity,
10,
) || 0) *
item.price,
)}
</span>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addItem}
>
<Plus className="h-4 w-4" />
Tambah Item
</Button>
<InputError message={errors.items} />
</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>
Status{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
value={status}
onValueChange={setStatus}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map(
(opt) => (
<SelectItem
key={
opt.value
}
value={
opt.value
}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.status}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan
</Label>
<Textarea
id="description"
value={description}
onChange={(e) =>
setDescription(
e.target.value,
)
}
placeholder="Masukkan keterangan"
/>
<InputError
message={errors.description}
/>
</div>
<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>
<Button
type="submit"
className="w-full"
disabled={
processing ||
!invoiceNumber ||
!customerName ||
!date ||
items.length === 0 ||
items.some((i) => !i.name)
}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false);
setDeleteItemIndex(null);
}
}}
title="Hapus Item"
description="Apakah Anda yakin ingin menghapus item ini?"
confirmLabel="Hapus"
variant="destructive"
onConfirm={() => {
if (deleteItemIndex !== null) {
removeItem(deleteItemIndex);
}
setDeleteConfirmOpen(false);
setDeleteItemIndex(null);
}}
/>
</div>
</>
);
}