dress/resources/js/pages/admin/finance/expense/index.tsx

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',
},
],
};