132 lines
16 KiB
TypeScript
132 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import { AlertCircle, ChevronLeft, ChevronRight, ClipboardList, FileDown, FileText, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||
import { Button, Checkbox, DatePicker, Dialog, Loading, Radio, Space, Table, Tag, Tooltip } from "tdesign-react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { api } from "../../services/api";
|
||
import ExpenseDrawer from "./ExpenseDrawer";
|
||
import ExpenseDetail from "./ExpenseDetail";
|
||
import AccessibleInput from "../../components/AccessibleInput";
|
||
import { dateText, money, monthNow } from "./date";
|
||
import type { Expense, Notify } from "./types";
|
||
|
||
type Props = { timezone?: string; notify?: Notify; sessionKey?: string };
|
||
const EXPORT_JOB_STORAGE_KEY = "tallynote.exportJobId";
|
||
|
||
function savedExportJob(storageKey: string): string | null {
|
||
try { return window.sessionStorage.getItem(storageKey); } catch { return null; }
|
||
}
|
||
|
||
export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai", notify, sessionKey = "current" }: Props) {
|
||
const timezone = timezoneProp;
|
||
const exportStorageKey = `${EXPORT_JOB_STORAGE_KEY}.${sessionKey}`;
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const rawMonthParam = searchParams.get("month");
|
||
const rawMonth = rawMonthParam || monthNow(timezone);
|
||
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : monthNow(timezone);
|
||
const rawStatus = searchParams.get("status");
|
||
const status: "unreimbursed" | "reimbursed" = rawStatus === "reimbursed" ? "reimbursed" : "unreimbursed";
|
||
const rawQuery = searchParams.get("query") || "";
|
||
const query = rawQuery.slice(0, 200);
|
||
const rawMissingInvoice = searchParams.get("missingInvoice");
|
||
const missingInvoice = searchParams.get("missingInvoice") === "true";
|
||
const [queryDraft, setQueryDraft] = useState(query);
|
||
const [items, setItems] = useState<Expense[]>([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(null); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
|
||
const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(searchParams);
|
||
let changed = false;
|
||
if (rawMonthParam && rawMonthParam !== month) { params.set("month", month); changed = true; }
|
||
if (rawStatus && rawStatus !== status) { params.set("status", status); changed = true; }
|
||
if (rawQuery !== query) {
|
||
if (query) params.set("query", query); else params.delete("query");
|
||
changed = true;
|
||
}
|
||
if (rawMissingInvoice && rawMissingInvoice !== "true") { params.delete("missingInvoice"); changed = true; }
|
||
if (changed) setSearchParams(params, { replace: true });
|
||
}, [month, query, rawMissingInvoice, rawMonthParam, rawQuery, rawStatus, searchParams, setSearchParams, status]);
|
||
useEffect(() => { setQueryDraft(query); }, [query]);
|
||
const updateFilters = (next: { month?: string; status?: "unreimbursed" | "reimbursed"; query?: string; missingInvoice?: boolean }) => {
|
||
const params = new URLSearchParams(searchParams);
|
||
params.set("month", next.month ?? month);
|
||
params.set("status", next.status ?? status);
|
||
const nextQuery = (next.query ?? query).trim().slice(0, 200);
|
||
if (nextQuery) params.set("query", nextQuery); else params.delete("query");
|
||
if (next.missingInvoice ?? missingInvoice) params.set("missingInvoice", "true"); else params.delete("missingInvoice");
|
||
setSearchParams(params);
|
||
};
|
||
const load = async () => { const s = ++sequence.current; const requestedKey = filterKey; setLoading(true); setError(""); try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(query)}&missingInvoice=${missingInvoice}`); if (s === sequence.current) { setItems(result.items); setSummary(result.summary); setLoadedFilterKey(requestedKey); setSelectedKeys(keys => keys.filter(k => result.items.some(x => x.id === k))); } } catch (e) { if (s === sequence.current) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); setError((e as Error).message); } } finally { if (s === sequence.current) setLoading(false); } };
|
||
useEffect(() => { setSelectedKeys([]); setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); void load(); }, [filterKey]);
|
||
const selectedTotal = useMemo(() => items.filter(i => selectedKeys.includes(i.id)).reduce((sum, i) => sum + i.amountCents, 0), [items, selectedKeys]);
|
||
const shiftMonth = (delta: number) => { const [rawYear, rawMonthNumber] = month.split("-").map(Number); const y = rawYear || new Date().getFullYear(); const m = rawMonthNumber || 1; const d = new Date(y, m - 1 + delta, 1); updateFilters({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` }); };
|
||
const rememberExportJob = (jobId: string | null) => {
|
||
setExporting(jobId);
|
||
try {
|
||
if (jobId) window.sessionStorage.setItem(exportStorageKey, jobId);
|
||
else window.sessionStorage.removeItem(exportStorageKey);
|
||
} catch { /* Session storage can be unavailable in hardened browsers. */ }
|
||
};
|
||
const exportAll = async () => { if (exporting || exportStarting.current) return; exportStarting.current = true; try { const body = selectedKeys.length ? { ids: selectedKeys, includeManifest } : { month, status, query, missingInvoice, includeManifest }; const r = await api<{ job: { id: string } }>("/api/exports", { method: "POST", body: JSON.stringify(body) }); setExportIssue(""); rememberExportJob(r.job.id); notify?.(includeManifest ? "导出任务已创建(含 manifest.json)" : "导出任务已创建", "info"); } catch (e) { notify?.((e as Error).message, "error"); } finally { exportStarting.current = false; } };
|
||
useEffect(() => {
|
||
if (!exporting) return;
|
||
let disposed = false;
|
||
let timer: number | undefined;
|
||
let failureCount = 0;
|
||
const finish = () => { setExportIssue(""); rememberExportJob(null); };
|
||
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
|
||
const poll = async () => {
|
||
try {
|
||
const r = await api<{ job: { status: string; errorMessage?: string } }>(`/api/exports/${exporting}`);
|
||
if (disposed) return;
|
||
failureCount = 0;
|
||
setExportIssue("");
|
||
if (r.job.status === "ready") {
|
||
const downloadUrl = `/api/exports/${exporting}/download`;
|
||
finish();
|
||
window.location.href = downloadUrl;
|
||
} else if (["failed", "expired"].includes(r.job.status)) {
|
||
finish();
|
||
notify?.(r.job.errorMessage || "导出失败", "error");
|
||
} else {
|
||
schedule(1000);
|
||
}
|
||
} catch {
|
||
if (disposed) return;
|
||
failureCount += 1;
|
||
setExportIssue("网络连接不稳定,导出仍在后台进行,正在重新查询状态…");
|
||
schedule(Math.min(1000 * (2 ** Math.min(failureCount, 3)), 8000));
|
||
}
|
||
};
|
||
void poll();
|
||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
||
}, [exporting, notify]);
|
||
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
|
||
const columns = [
|
||
{ colKey: "row-select", type: "multiple" },
|
||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong>{money(row.amountCents)}</strong> },
|
||
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
|
||
{ colKey: "paymentProofCount", title: "凭证" },
|
||
{ colKey: "invoiceCount", title: "发票", cell: ({ row }: any) => row.invoiceCount === 0 ? <Tag theme="danger" title={row.invoiceMissingReason || "缺发票"}>{row.invoiceMissingReason ? `无发票:${row.invoiceMissingReason}` : "缺发票"}</Tag> : row.invoiceCount },
|
||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
|
||
{ colKey: "op", title: "操作", width: 150, cell: ({ row }: any) => {
|
||
const label = row.note || "账目";
|
||
return <Space className="tn-action-group"><Tooltip content="查看详情"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("detail"); }} aria-label={`查看 ${label}`} icon={<FileText size={16} />} /></Tooltip><Tooltip content="编辑"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("edit"); }} aria-label={`编辑 ${label}`} icon={<Pencil size={15} />} /></Tooltip><Tooltip content="移入回收站"><Button variant="text" shape="square" theme="danger" onClick={() => setTrashTarget(row)} aria-label={`将 ${label} 移入回收站`} icon={<Trash2 size={15} />} /></Tooltip></Space>;
|
||
}},
|
||
];
|
||
const emptyState = query || missingInvoice
|
||
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>没有符合当前筛选条件的账目</p><Button variant="outline" onClick={() => { setQueryDraft(""); updateFilters({ query: "", missingInvoice: false }); }}>清除筛选</Button></div>
|
||
: status === "reimbursed"
|
||
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无已报销账目</p><Button variant="outline" onClick={() => updateFilters({ status: "unreimbursed" })}>查看未报销</Button></div>
|
||
: <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无未报销账目</p><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={15} />}>新增第一笔</Button></div>;
|
||
const dataReady = loadedFilterKey === filterKey;
|
||
return <div className="tn-page expenses-page"><div className="tn-page-head expenses-head"><div><h1 className="tn-page-title">账目列表</h1></div><div className="tn-page-actions"><Checkbox checked={includeManifest} onChange={setIncludeManifest}>包含 manifest.json</Checkbox><Button variant="outline" onClick={() => void exportAll()} disabled={Boolean(exporting) || (!selectedKeys.length && (!items.length || !dataReady))} icon={<FileDown size={16} />}>{exporting ? "导出中…" : selectedKeys.length ? `导出所选(${selectedKeys.length})` : "导出筛选结果"}</Button><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={16} />}>新增账目</Button></div></div>
|
||
<div className="tn-toolbar expenses-toolbar"><div className="tn-expense-month-controls"><Tooltip content="上个月"><Button variant="text" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={18} />} /></Tooltip><DatePicker className="tn-month-picker" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) updateFilters({ month: next }); }} placeholder="选择月份" inputProps={{ "aria-label": "账目月份" } as any} /><Tooltip content="下个月"><Button variant="text" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={18} />} /></Tooltip></div><Radio.Group className="tn-segmented" theme="button" variant="primary-filled" value={status} onChange={(value: any) => updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态"><Radio.Button value="unreimbursed">未报销</Radio.Button><Radio.Button value="reimbursed">已报销</Radio.Button></Radio.Group><div className="tn-expense-search-controls"><AccessibleInput inputAriaLabel="搜索备注" className="tn-expense-search" value={queryDraft} onChange={setQueryDraft} onEnter={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={<Search size={16} />} suffix={queryDraft ? <Tooltip content="清除搜索"><Button variant="text" shape="square" onClick={() => { setQueryDraft(""); updateFilters({ query: "" }); }} aria-label="清除搜索" icon={<X size={14} />} /></Tooltip> : undefined} /><Button variant="outline" onClick={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} disabled={loading} icon={<Search size={15} />}>搜索</Button></div><div className="tn-expense-filter-actions"><Checkbox checked={missingInvoice} onChange={checked => updateFilters({ missingInvoice: checked })}>缺发票</Checkbox><Tooltip content="刷新当前结果"><Button variant="outline" shape="square" onClick={() => void load()} disabled={loading} aria-label="刷新当前结果" icon={<RefreshCw size={15} />} /></Tooltip></div></div>
|
||
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
|
||
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>停止等待</Button></div>}
|
||
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
|
||
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
|
||
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
|
||
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</Dialog>}
|
||
</div>;
|
||
}
|