454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
import { Head, useForm, router } from '@inertiajs/react';
|
|
import type { Expense } from '@/types';
|
|
import { ColumnDef } from '@tanstack/react-table';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Trash2, Pencil, ImagePlus, X } from 'lucide-react';
|
|
import { DataTable } from '@/components/data-table';
|
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Field, FieldGroup } from "@/components/ui/field"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { useState, useRef } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
|
|
import { Tooltip } from '@/components/ui/tooltip';
|
|
import expenseRoutes from '@/routes/expense';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogMedia,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import { NumericFormat } from 'react-number-format';
|
|
|
|
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
|
|
|
|
const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm<{
|
|
name: string;
|
|
amount: string;
|
|
image: File | null;
|
|
}>({
|
|
name: '',
|
|
amount: '',
|
|
image: null,
|
|
});
|
|
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const [selectedProof, setSelectedProof] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
const [expenseToDelete, setExpenseToDelete] = useState<Expense | null>(null);
|
|
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
|
const [rowSelection, setRowSelection] = useState({});
|
|
|
|
const onEdit = (expense: Expense) => {
|
|
setIsEditing(true);
|
|
setSelectedExpense(expense);
|
|
setData({
|
|
name: expense.name,
|
|
amount: expense.amount.toString(),
|
|
image: null,
|
|
});
|
|
setImagePreview(expense.proof_url || null);
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const onDelete = (expense: Expense) => {
|
|
setExpenseToDelete(expense);
|
|
setIsDeleteDialogOpen(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (expenseToDelete) {
|
|
router.delete(expenseRoutes.destroy(expenseToDelete.id).url, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
setIsDeleteDialogOpen(false);
|
|
setExpenseToDelete(null);
|
|
setRowSelection({});
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const confirmBulkDelete = () => {
|
|
router.post(expenseRoutes.bulkDestroy().url, {
|
|
ids: rowsToDelete.map((row: any) => row.id),
|
|
_method: 'DELETE'
|
|
}, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
setIsBulkDeleteDialogOpen(false);
|
|
setRowsToDelete([]);
|
|
setRowSelection({});
|
|
},
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setIsOpen(false);
|
|
setTimeout(() => {
|
|
setIsEditing(false);
|
|
setSelectedExpense(null);
|
|
setImagePreview(null);
|
|
reset();
|
|
clearErrors();
|
|
}, 200);
|
|
};
|
|
|
|
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
setData('image', file);
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setImagePreview(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const removeImage = () => {
|
|
setData('image', null);
|
|
setImagePreview(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (isEditing && selectedExpense) {
|
|
router.post(expenseRoutes.update(selectedExpense.id).url, {
|
|
...data,
|
|
_method: 'PATCH',
|
|
}, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
closeModal();
|
|
},
|
|
});
|
|
} else {
|
|
post(expenseRoutes.store().url, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
closeModal();
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('id-ID', {
|
|
style: 'currency',
|
|
currency: 'IDR',
|
|
minimumFractionDigits: 0,
|
|
}).format(amount);
|
|
};
|
|
|
|
const columns: ColumnDef<Expense>[] = [
|
|
{
|
|
accessorKey: "proof_url",
|
|
header: "Bukti",
|
|
cell: ({ row }) => {
|
|
const url = row.original.proof_url;
|
|
return url ? (
|
|
<button onClick={() => setSelectedProof(url)} className="block w-fit">
|
|
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
|
|
</button>
|
|
) : (
|
|
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
|
|
<ImagePlus className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
);
|
|
},
|
|
meta: { title: "Bukti" },
|
|
},
|
|
{
|
|
accessorKey: "user.name",
|
|
header: ({ column }) => {
|
|
return (
|
|
<DataTableColumnHeader column={column} title="Pegawai" />
|
|
)
|
|
},
|
|
cell: ({ row }) => row.original.user?.name,
|
|
meta: { title: "Pegawai" },
|
|
},
|
|
{
|
|
accessorKey: "name",
|
|
header: ({ column }) => {
|
|
return (
|
|
<DataTableColumnHeader column={column} title="Nama" />
|
|
)
|
|
},
|
|
meta: { title: "Nama" },
|
|
},
|
|
{
|
|
accessorKey: "amount",
|
|
header: ({ column }) => {
|
|
return (
|
|
<DataTableColumnHeader column={column} title="Nominal" />
|
|
)
|
|
},
|
|
cell: ({ row }) => formatCurrency(row.original.amount),
|
|
meta: { title: "Nominal" },
|
|
},
|
|
{
|
|
accessorKey: "created_at",
|
|
header: ({ column }) => {
|
|
return (
|
|
<DataTableColumnHeader column={column} title="Tanggal" />
|
|
)
|
|
},
|
|
cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
}),
|
|
meta: { title: "Tanggal" },
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Aksi",
|
|
cell: ({ row }) => {
|
|
const expense = row.original;
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(expense)}>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Ubah</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(expense)}>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Hapus</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
},
|
|
meta: { title: "Aksi" },
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 p-6">
|
|
<Head title="Pengeluaran" />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Pengeluaran</h1>
|
|
</div>
|
|
<Button onClick={() => setIsOpen(true)}>
|
|
Tambah
|
|
</Button>
|
|
</div>
|
|
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit}>
|
|
<FieldGroup>
|
|
<Field>
|
|
<Label htmlFor="name">Nama</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={data.name}
|
|
onChange={e => setData('name', e.target.value)}
|
|
autoComplete='off'
|
|
placeholder='Contoh: Bayar Listrik'
|
|
maxLength={100}
|
|
/>
|
|
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="amount">Nominal</Label>
|
|
<NumericFormat
|
|
id="amount"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.amount}
|
|
onValueChange={(values) => {
|
|
setData('amount', values.value)
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
{errors.amount && <p className="text-xs text-red-500">{errors.amount}</p>}
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label>Bukti</Label>
|
|
<div className="mt-2">
|
|
{imagePreview ? (
|
|
<div className="relative inline-block">
|
|
<img
|
|
src={imagePreview}
|
|
alt="Preview"
|
|
className="object-cover rounded-lg border shadow-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={removeImage}
|
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="h-40 w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
|
>
|
|
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
|
|
<span className="text-sm text-muted-foreground font-medium">Klik untuk upload bukti</span>
|
|
<span className="text-xs text-muted-foreground mt-1">PNG, JPG up to 5MB</span>
|
|
</div>
|
|
)}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
accept="image/*"
|
|
onChange={onImageChange}
|
|
/>
|
|
</div>
|
|
{errors.image && <p className="text-xs text-red-500 mt-1">{errors.image}</p>}
|
|
</Field>
|
|
</FieldGroup>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" onClick={closeModal}>Batal</Button>
|
|
<Button type="submit" disabled={processing}>
|
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!selectedProof} onOpenChange={() => setSelectedProof(null)}>
|
|
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
|
|
<div className="relative group">
|
|
<img
|
|
src={selectedProof || ''}
|
|
alt="Proof"
|
|
className="w-full h-auto max-h-[80vh] object-contain rounded-lg"
|
|
/>
|
|
<button
|
|
onClick={() => setSelectedProof(null)}
|
|
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 backdrop-blur-sm transition-all shadow-xl"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
|
<CardContent className="p-0">
|
|
<DataTable
|
|
columns={columns}
|
|
data={expenses}
|
|
rowSelection={rowSelection}
|
|
onRowSelectionChange={setRowSelection}
|
|
bulkActions={[
|
|
{
|
|
label: 'Hapus Terpilih',
|
|
onClick: (rows) => {
|
|
setRowsToDelete(rows);
|
|
setIsBulkDeleteDialogOpen(true);
|
|
},
|
|
icon: Trash2,
|
|
variant: 'destructive'
|
|
},
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent size="default">
|
|
<AlertDialogHeader>
|
|
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
|
<Trash2 className="size-5" />
|
|
</AlertDialogMedia>
|
|
<AlertDialogTitle>Hapus pengeluaran?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Tindakan ini tidak dapat dibatalkan. Pengeluaran <strong>{expenseToDelete?.name}</strong> akan dihapus secara permanen.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
|
<AlertDialogContent size="default">
|
|
<AlertDialogHeader>
|
|
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
|
<Trash2 className="size-5" />
|
|
</AlertDialogMedia>
|
|
<AlertDialogTitle>Hapus {rowsToDelete.length} pengeluaran?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmBulkDelete}
|
|
variant="destructive"
|
|
>
|
|
Hapus
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ExpenseIndex.layout = {
|
|
breadcrumbs: [
|
|
{
|
|
title: 'Keuangan',
|
|
},
|
|
],
|
|
};
|