Files
star-erp/resources/js/Pages/UtilityFee/Index.tsx
sky121113 c1d302f03e
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
更新 UI 一致性規範與公共事業費樣式
2026-01-20 10:41:35 +08:00

417 lines
18 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,
ArrowUpDown,
ArrowUp,
ArrowDown
} 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 { Badge } from "@/Components/ui/badge";
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";
import { Can } from "@/Components/Permission/Can";
import { formatDateWithDayOfWeek, formatInvoiceNumber } from "@/utils/format";
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 | null;
sort_direction?: "asc" | "desc" | null;
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<string | null>(filters.sort_field || 'transaction_date');
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || '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) => {
let newField: string | null = field;
let newDirection: "asc" | "desc" | null = "asc";
if (sortField === field) {
if (sortDirection === "asc") {
newDirection = "desc";
} else {
newDirection = null;
newField = null;
}
}
setSortField(newField);
setSortDirection(newDirection);
router.get(
route("utility-fees.index"),
{
search: searchTerm,
category: categoryFilter,
date_start: dateStart,
date_end: dateEnd,
sort_field: newField,
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);
},
});
}
};
const SortIcon = ({ field }: { field: string }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
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>
<Can permission="utility_fees.create">
<Button
onClick={openAddDialog}
className="button-filled-primary gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</Can>
</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"
/>
{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="篩選類別"
/>
{/* Date Range Start */}
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
<Input
type="date"
value={dateStart}
onChange={(e) => setDateStart(e.target.value)}
className="pl-9 bg-white block w-full"
placeholder="開始日期"
/>
</div>
{/* Date Range End */}
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
<Input
type="date"
value={dateEnd}
onChange={(e) => setDateEnd(e.target.value)}
className="pl-9 bg-white block w-full"
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>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[120px]">
<button
onClick={() => handleSort('transaction_date')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="transaction_date" />
</button>
</TableHead>
<TableHead>
<button
onClick={() => handleSort('category')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="category" />
</button>
</TableHead>
<TableHead className="text-right">
<div className="flex justify-end">
<button
onClick={() => handleSort('amount')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="amount" />
</button>
</div>
</TableHead>
<TableHead>
<button
onClick={() => handleSort('invoice_number')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="invoice_number" />
</button>
</TableHead>
<TableHead> / </TableHead>
<TableHead className="text-center w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fees.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<div className="flex flex-col items-center justify-center space-y-2 py-8">
<FileText className="h-8 w-8 text-gray-300" />
<p className="text-gray-500"></p>
</div>
</TableCell>
</TableRow>
) : (
fees.data.map((fee, index) => (
<TableRow key={fee.id}>
<TableCell className="text-gray-500 font-medium text-center">
{fees.from + index}
</TableCell>
<TableCell className="font-medium text-gray-700">
{formatDateWithDayOfWeek(fee.transaction_date)}
</TableCell>
<TableCell>
<Badge variant="outline">
{fee.category}
</Badge>
</TableCell>
<TableCell className="text-right font-bold text-gray-900">
$ {Number(fee.amount).toLocaleString()}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{formatInvoiceNumber(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">
<Can permission="utility_fees.edit">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={() => openEditDialog(fee)}
>
<Pencil className="h-4 w-4" />
</Button>
</Can>
<Can permission="utility_fees.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
onClick={() => confirmDelete(fee.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
</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>
);
}