Files
star-erp/resources/js/Pages/Accounting/Report.tsx
sky121113 299cf37054 fix: 修正全系統側邊欄捲軸重置問題
在所有報表與管理頁面的 router.get 調用中加入 preserveScroll: true。
受影響模組包括:
- 財務管理 (會計報表、公用事業費)
- 庫存管理 (庫存查詢、倉庫管理、進貨、調整、調撥)
- 生產管理 (工單管理、配方管理)
- 採購管理 (採購單)
- 銷售與發貨管理 (銷售單、發貨單、匯入管理)
- 系統管理 (使用者、角色、操作紀錄)
2026-02-25 14:04:22 +08:00

392 lines
19 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";
import { Checkbox } from "@/Components/ui/checkbox";
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 [selectedIds, setSelectedIds] = useState<string[]>([]);
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, preserveScroll: true }
);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("accounting.report"),
{
date_start: dateStart,
date_end: dateEnd,
per_page: value,
},
{ preserveState: true, preserveScroll: true }
);
};
const handleClearFilters = () => {
setDateStart("");
setDateEnd("");
setPerPage("10");
setSelectedIds([]);
router.get(route("accounting.report"), {}, { preserveState: false });
};
const toggleSelectAll = () => {
if (selectedIds.length === records.data.length) {
setSelectedIds([]);
} else {
setSelectedIds(records.data.map(r => r.id));
}
};
const toggleSelect = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const handleExport = () => {
const query: any = {
date_start: dateStart,
date_end: dateEnd,
};
if (selectedIds.length > 0) {
query.selected_ids = selectedIds.join(',');
}
window.location.href = route("accounting.export", query);
};
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" />
{selectedIds.length > 0 ? `匯出已選 (${selectedIds.length})` : '匯出 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">
{/* Top Config: Date Range & Quick Buttons */}
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
<div className="flex-none 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>
{/* Date Inputs */}
<div className="w-full lg:flex-1">
<div className="grid grid-cols-2 gap-2">
<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>
{/* Row 2: Actions */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
<div className="md:col-span-9"></div>
<div className="md:col-span-3 flex items-center gap-2">
<Button
variant="outline"
onClick={handleClearFilters}
className="flex-1 items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex-1 button-filled-primary h-9 gap-2"
>
<Filter className="h-4 w-4" />
</Button>
</div>
</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-[50px] text-center">
<Checkbox
checked={records.data.length > 0 && selectedIds.length === records.data.length}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<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={6}>
<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="text-center">
<Checkbox
checked={selectedIds.includes(record.id)}
onCheckedChange={() => toggleSelect(record.id)}
aria-label={`Select record ${record.id}`}
/>
</TableCell>
<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>
);
}