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(() => (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(() => (isCached ? filterKey : null)); const [selectedKeys, setSelectedKeys] = useState([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState(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) => {money(row.amountCents)} }, { colKey: "note", title: "备注", cell: ({ row }: any) => row.note || 无备注, ellipsis: true }, { colKey: "paymentProofCount", title: "凭证" }, { colKey: "invoiceCount", title: "发票", cell: ({ row }: any) => row.invoiceCount === 0 ? {row.invoiceMissingReason ? `无发票:${row.invoiceMissingReason}` : "缺发票"} : row.invoiceCount }, { colKey: "status", title: "状态", cell: ({ row }: any) => {row.status === "reimbursed" ? "已报销" : "未报销"} }, { colKey: "op", title: "操作", width: 150, cell: ({ row }: any) => { const label = row.note || "账目"; return : status === "reimbursed" ?

本月暂无已报销账目

:

本月暂无未报销账目

; const dataReady = loadedFilterKey === filterKey; return

账目列表

包含 manifest.json
updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态">未报销已报销
{ if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={} suffix={queryDraft ?
updateFilters({ missingInvoice: checked })}>缺发票
{summary.count} 笔{money(summary.amountCents)}{selectedKeys.length > 0 && <>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}}
{exportIssue &&
{exportIssue}
} {error &&
{error}
} {error ? null : (!items.length && (loading || !dataReady)) ?
: !items.length ? emptyState :
setSelectedKeys(keys as string[])} hover stripe />} {drawer === "new" && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />} {trashTarget && { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。} ; }