Files
star-erp/resources/js/Pages/UtilityFee/Index.tsx
sky121113 32c2612a5f
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 56s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat(accounting): 實作公共事業費管理與會計支出報表功能
2026-01-20 09:44:05 +08:00

365 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Plus,
Search,
X,
Pencil,
Trash2,
FileText,
Calendar,
Filter
} from 'lucide-react';
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react";
import Pagination from "@/Components/shared/Pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { toast } from "sonner";
import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
interface PageProps {
fees: {
data: UtilityFee[];
links: any[];
from: number;
to: number;
total: number;
current_page: number;
last_page: number;
per_page: number;
};
availableCategories: string[];
filters: {
search?: string;
category?: string;
date_start?: string;
date_end?: string;
sort_field?: string;
sort_direction?: string;
per_page?: string;
};
}
export default function UtilityFeeIndex({ fees, availableCategories, filters }: PageProps) {
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [categoryFilter, setCategoryFilter] = useState<string>(filters.category || "all");
const [dateStart, setDateStart] = useState(filters.date_start || "");
const [dateEnd, setDateEnd] = useState(filters.date_end || "");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
const [deletingFeeId, setDeletingFeeId] = useState<number | null>(null);
// Sorting
const [sortField, setSortField] = useState(filters.sort_field || 'transaction_date');
const [sortDirection, setSortDirection] = useState(filters.sort_direction || 'desc');
const handleSearch = () => {
router.get(
route("utility-fees.index"),
{
search: searchTerm,
category: categoryFilter,
date_start: dateStart,
date_end: dateEnd,
sort_field: sortField,
sort_direction: sortDirection,
},
{ preserveState: true }
);
};
const handleClearFilters = () => {
setSearchTerm("");
setCategoryFilter("all");
setDateStart("");
setDateEnd("");
router.get(route("utility-fees.index"));
};
const handleSort = (field: string) => {
const newDirection = sortField === field && sortDirection === 'asc' ? 'desc' : 'asc';
setSortField(field);
setSortDirection(newDirection);
router.get(
route("utility-fees.index"),
{
search: searchTerm,
category: categoryFilter,
date_start: dateStart,
date_end: dateEnd,
sort_field: field,
sort_direction: newDirection,
},
{ preserveState: true }
);
};
const openAddDialog = () => {
setEditingFee(null);
setIsDialogOpen(true);
};
const openEditDialog = (fee: UtilityFee) => {
setEditingFee(fee);
setIsDialogOpen(true);
};
const confirmDelete = (id: number) => {
setDeletingFeeId(id);
setIsDeleteDialogOpen(true);
};
const handleDelete = () => {
if (deletingFeeId) {
router.delete(route("utility-fees.destroy", deletingFeeId), {
onSuccess: () => {
toast.success("紀錄已刪除");
setIsDeleteDialogOpen(false);
},
});
}
};
return (
<AuthenticatedLayout breadcrumbs={[{ label: "財務管理", href: "#" }, { label: "公共事業費", href: route("utility-fees.index") }]}>
<Head title="公共事業費管理" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<FileText className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Button
onClick={openAddDialog}
className="button-filled-primary gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋發票、備註..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-10 h-10"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Category Filter */}
<SearchableSelect
value={categoryFilter}
onValueChange={setCategoryFilter}
options={[
{ label: "所有類別", value: "all" },
...availableCategories.map(c => ({ label: c, value: c }))
]}
placeholder="篩選類別"
className="h-10"
/>
{/* Date Range Start */}
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4 pointer-events-none" />
<Input
type="date"
value={dateStart}
onChange={(e) => setDateStart(e.target.value)}
className="pl-10 h-10"
placeholder="開始日期"
/>
</div>
{/* Date Range End */}
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4 pointer-events-none" />
<Input
type="date"
value={dateEnd}
onChange={(e) => setDateEnd(e.target.value)}
className="pl-10 h-10"
placeholder="結束日期"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
onClick={handleClearFilters}
className="button-outlined-primary h-9"
>
</Button>
<Button
onClick={handleSearch}
className="button-filled-primary h-9 gap-2"
>
<Filter className="h-4 w-4" />
</Button>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50 text-gray-600 font-bold uppercase tracking-wider text-xs">
<TableRow>
<TableHead
className="cursor-pointer hover:text-primary transition-colors py-4 px-4 text-center"
onClick={() => handleSort('transaction_date')}
>
{sortField === 'transaction_date' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-primary transition-colors py-4 px-4"
onClick={() => handleSort('category')}
>
{sortField === 'category' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-primary transition-colors py-4 px-4 text-right"
onClick={() => handleSort('amount')}
>
{sortField === 'amount' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-primary transition-colors py-4 px-4"
onClick={() => handleSort('invoice_number')}
>
{sortField === 'invoice_number' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead className="py-4 px-4"> / </TableHead>
<TableHead className="py-4 px-4 text-center w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fees.data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-48 text-center text-gray-500">
<div className="flex flex-col items-center justify-center space-y-2">
<FileText className="h-8 w-8 text-gray-300" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
fees.data.map((fee) => (
<TableRow key={fee.id} className="hover:bg-gray-50/50 transition-colors">
<TableCell className="text-center font-medium">{fee.transaction_date}</TableCell>
<TableCell>
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-primary/10 text-primary">
{fee.category}
</span>
</TableCell>
<TableCell className="text-right font-bold text-gray-900">
$ {Number(fee.amount).toLocaleString()}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{fee.invoice_number || '-'}
</TableCell>
<TableCell className="max-w-xs truncate text-gray-600" title={fee.description}>
{fee.description || '-'}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
className="button-outlined-primary h-8 w-8 p-0"
onClick={() => openEditDialog(fee)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="button-outlined-error h-8 w-8 p-0"
onClick={() => confirmDelete(fee.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="border-t p-4">
<Pagination links={fees.links} />
</div>
</div>
</div>
<UtilityFeeDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
fee={editingFee}
availableCategories={availableCategories}
/>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AuthenticatedLayout>
);
}