Files
TallyNote/web-next/src/pages/expenses/ExpensesPage.tsx
T

140 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 { BeamLoading } from "../../components/BeamLoading";
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";
let expensesCache: { key: string; items: Expense[]; summary: { count: number; amountCents: number } } | null = null;
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 filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
const isCached = expensesCache?.key === filterKey;
const [queryDraft, setQueryDraft] = useState(query);
const [items, setItems] = useState<Expense[]>(() => (isCached ? expensesCache!.items : []));
const [summary, setSummary] = useState(() => (isCached ? expensesCache!.summary : { count: 0, amountCents: 0 }));
const [loading, setLoading] = useState(() => !isCached);
const [error, setError] = useState("");
const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(() => (isCached ? filterKey : 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);
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))); expensesCache = { key: requestedKey, items: result.items, summary: result.summary }; } } 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([]); if (expensesCache?.key !== filterKey) { 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 : (!items.length && (loading || !dataReady)) ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading 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 width="540px" 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>;
}