Files
star-erp/resources/js/Pages/Accounting/Report.tsx
sky121113 b3299618ce
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
完善公共事業費用與會計報表權限設定
- 新增 utility_fees 與 accounting 相關權限至 PermissionSeeder
- 更新 RoleController 加入權限群組中文標題映射
- 為會計報表匯出功能加上權限保護
- 前端加入 Can 組件保護按鈕顯示
- 更新權限管理 Skill 文件,補充 UI 顯示設定步驟
2026-01-21 10:55:11 +08:00

349 lines
17 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 { Label } from "@/Components/ui/label";
import {
BarChart3,
Download,
Calendar,
Filter,
TrendingDown,
Package,
Pocket,
RotateCcw,
FileText
} from 'lucide-react';
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { getDateRange, formatDateWithDayOfWeek } from "@/utils/format";
import { Badge } from "@/Components/ui/badge";
import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Can } from "@/Components/Permission/Can";
interface Record {
id: string;
date: string;
source: string;
category: string;
item: string;
reference: string;
invoice_number?: string;
amount: number | string;
}
interface PageProps {
records: {
data: Record[];
links: any[];
total: number;
from: number;
to: number;
current_page: number;
};
summary: {
total_amount: number;
purchase_total: number;
utility_total: number;
record_count: number;
};
filters: {
date_start: string;
date_end: string;
per_page?: number;
};
}
export default function AccountingReport({ records, summary, filters }: PageProps) {
const [dateStart, setDateStart] = useState(filters.date_start);
const [dateEnd, setDateEnd] = useState(filters.date_end);
// Determine initial range type
const today = new Date().toISOString().split('T')[0];
const initialRangeType = (filters.date_start === today && filters.date_end === today) ? "today" : "custom";
const [dateRangeType, setDateRangeType] = useState(initialRangeType);
const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
const handleDateRangeChange = (type: string) => {
setDateRangeType(type);
if (type === "custom") return;
const { start, end } = getDateRange(type);
setDateStart(start);
setDateEnd(end);
};
const handleFilter = () => {
router.get(
route("accounting.report"),
{
date_start: dateStart,
date_end: dateEnd,
per_page: perPage,
},
{ preserveState: true }
);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("accounting.report"),
{
date_start: dateStart,
date_end: dateEnd,
per_page: value,
},
{ preserveState: true }
);
};
const handleClearFilters = () => {
setDateStart("");
setDateEnd("");
setPerPage("10");
router.get(route("accounting.report"), {}, { preserveState: false });
};
const handleExport = () => {
window.location.href = route("accounting.export", {
date_start: dateStart,
date_end: dateEnd,
});
};
return (
<AuthenticatedLayout breadcrumbs={[{ label: "報表管理", href: "#" }, { label: "會計報表", href: route("accounting.report"), isPage: true }]}>
<Head title="會計報表" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<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">
<BarChart3 className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Can permission="accounting.export">
<Button
onClick={handleExport}
variant="outline"
className="button-outlined-primary gap-2"
>
<Download className="h-4 w-4" /> CSV
</Button>
</Can>
</div>
{/* Filters with Quick Date Range */}
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
<div className="md:col-span-6 space-y-2">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="flex flex-wrap gap-2">
{[
{ label: "今日", value: "today" },
{ label: "昨日", value: "yesterday" },
{ label: "本週", value: "this_week" },
{ label: "本月", value: "this_month" },
{ label: "上月", value: "last_month" },
].map((opt) => (
<Button
key={opt.value}
size="sm"
onClick={() => handleDateRangeChange(opt.value)}
className={
dateRangeType === opt.value
? 'button-filled-primary h-9 px-4 shadow-sm'
: 'button-outlined-primary h-9 px-4 bg-white'
}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="md:col-span-6">
<div className="grid grid-cols-2 gap-4 items-end">
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateStart}
onChange={(e) => {
setDateStart(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateEnd}
onChange={(e) => {
setDateEnd(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3">
<Button
variant="outline"
onClick={handleClearFilters}
className="flex items-center gap-2 button-outlined-primary h-9 ml-auto"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="button-filled-primary h-9 px-6 gap-2"
>
<Filter className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Compact Summary - Full Width Grid (Horizontal Style) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-red-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<TrendingDown className="h-6 w-6 text-red-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.total_amount).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<Package className="h-6 w-6 text-orange-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.purchase_total).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-blue-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<Pocket className="h-6 w-6 text-blue-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.utility_total).toLocaleString()}</span>
</div>
</div>
</div>
{/* Results Table */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[140px] text-center"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead>
<TableHead className="px-6"></TableHead>
<TableHead className="w-[180px] text-right px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.data.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
<FileText className="h-10 w-10 opacity-20" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
records.data.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-medium text-gray-700 text-center">
{formatDateWithDayOfWeek(record.date)}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className={
record.source === '採購單'
? 'bg-orange-50 text-orange-700 border-orange-100'
: 'bg-blue-50 text-blue-700 border-blue-100'
}>
{record.source}
</Badge>
</TableCell>
<TableCell className="text-gray-600 text-center">
<Badge variant="outline" className="font-normal border-gray-200">
{record.category}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{record.item}</span>
{record.invoice_number && (
<span className="text-xs text-gray-400">{record.invoice_number}</span>
)}
</div>
</TableCell>
<TableCell className="text-right font-bold text-gray-900 px-4">
$ {Number(record.amount).toLocaleString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Footer */}
<div className="mt-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
<Pagination links={records.links} />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}