Files
TallyNote/web-next/src/pages/expenses/ExpenseDetail.tsx
T
2026-09-04 14:24:54 +08:00

73 lines
11 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 { useCallback, useEffect, useRef, useState } from "react";
import { AlertCircle, ArrowDownToLine, FileText, Image as ImageIcon, Loader2, Search, Settings, Trash2, X } from "lucide-react";
import { Button, Dialog, Drawer, Loading, Space, Tag, Textarea, Tooltip } from "tdesign-react";
import { api, ApiError } from "../../services/api";
import { dateText, formatBytes, money } from "./date";
import type { Attachment, Expense, Notify, TimelineEvent } from "./types";
import { useDrawerFocus } from "../../hooks/useDrawerFocus";
type Props = { expense: Expense; timezone?: string; onClose: () => void; onUpdated: () => void; onRequestEdit: (expense: Expense) => void; notify?: Notify };
const TIMELINE_LABELS: Record<string, string> = {
"expense.created": "创建账目",
"expense.updated": "更新账目",
"expense.status_changed": "更新报销状态",
"expense.trashed": "移入回收站",
"expense.restored": "从回收站恢复",
"attachment.added": "添加附件",
"attachment.deleted": "删除附件",
};
export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onClose, onUpdated, onRequestEdit, notify }: Props) {
const [detail, setDetail] = useState(expense); const [timeline, setTimeline] = useState<TimelineEvent[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [action, setAction] = useState<"status" | "trash" | null>(null); const [removeTarget, setRemoveTarget] = useState<Attachment | null>(null); const [removeReason, setRemoveReason] = useState(""); const [preview, setPreview] = useState<Attachment | null>(null); const [removeError, setRemoveError] = useState("");
const requestSequence = useRef(0);
useDrawerFocus(true, ".tn-detail-drawer");
const load = useCallback(async () => {
const currentSequence = ++requestSequence.current;
const currentExpenseId = expense.id;
setLoading(true);
setError("");
try {
const result = await api<{ expense: Expense; timeline: TimelineEvent[] }>(`/api/expenses/${currentExpenseId}`);
if (currentSequence !== requestSequence.current || result.expense.id !== currentExpenseId) return;
setDetail(result.expense);
setTimeline(result.timeline || []);
} catch (caught) {
if (currentSequence === requestSequence.current) setError((caught as Error).message);
} finally {
if (currentSequence === requestSequence.current) setLoading(false);
}
}, [expense.id]);
useEffect(() => {
setDetail(expense);
setTimeline([]);
setRemoveTarget(null);
setPreview(null);
void load();
return () => { requestSequence.current += 1; };
}, [expense.id, load]);
const recoverConflict = (caught: unknown, setMessage: (message: string) => void) => {
if (!(caught instanceof ApiError) || caught.status !== 409) return false;
const current = (caught.details as { current?: Expense } | undefined)?.current;
if (!current) return false;
setDetail(current);
setMessage("此笔账目已被其他管理员更新,已为您自动同步最新记录,请确认后重试。");
return true;
};
const updateStatus = async () => { setBusy(true); try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); setAction(null); notify?.(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
const trash = async () => { setBusy(true); try { await api(`/api/expenses/${detail.id}`, { method: "DELETE", body: JSON.stringify({ version: detail.version }) }); notify?.("账目已移入回收站", "success"); setAction(null); onClose(); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } };
return <>
<Drawer className="tn-content-drawer tn-detail-drawer" placement="right" size="520px" visible destroyOnClose header="账目详情" onClose={onClose} footer={<Space><Button onClick={() => setAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button theme="danger" onClick={() => setAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></Space>}>
{loading ? <div role="status" aria-live="polite"><Loading text="正在加载账目详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
<div className="expense-detail-head"><strong>{money(detail.amountCents)}</strong><Button variant="outline" onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
<dl><div><dt>支付时间</dt><dd>{dateText(detail.paidAt, timezone)}</dd></div><div><dt>发票</dt><dd>{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <><Tag theme="warning">无发票</Tag>:{detail.invoiceMissingReason}</> : <Tag theme="danger">未说明</Tag>}</dd></div><div><dt>状态</dt><dd><Tag theme={detail.status === "reimbursed" ? "success" : "warning"}>{detail.status === "reimbursed" ? "已报销" : "未报销"}</Tag></dd></div><div><dt>备注</dt><dd>{detail.note || "无"}</dd></div></dl>
<h3>附件 <small>{detail.attachments?.length || 0}</small></h3><div className="expense-attachments">{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return <div className="expense-attachment" key={a.id}><span title={a.originalName}>{a.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />} {a.originalName}<small>{formatBytes(a.sizeBytes)}</small></span><Space>{a.previewable && <Tooltip content="预览附件" placement="left"><Button variant="text" shape="circle" onClick={() => setPreview(a)} aria-label={`预览 ${a.originalName}`}><Search size={15} /></Button></Tooltip>}<Tooltip content="下载附件" placement="left"><Button variant="text" shape="circle" onClick={() => { window.location.href = `/api/attachments/${a.id}/content?download=1`; }} aria-label={`下载 ${a.originalName}`}><ArrowDownToLine size={15} /></Button></Tooltip><Tooltip content={protectsLastProof ? "至少保留一张付款凭证" : "删除附件"} placement="left"><Button variant="text" shape="circle" theme="danger" disabled={protectsLastProof} onClick={() => { setRemoveReason(""); setRemoveError(""); setRemoveTarget(a); }} aria-label={protectsLastProof ? `不可删除最后一张付款凭证 ${a.originalName}` : `删除 ${a.originalName}`}><Trash2 size={14} /></Button></Tooltip></Space></div>; })}</div>
{timeline.length > 0 && <><h3>操作记录</h3><div className="expense-timeline">{timeline.slice(0, 12).map(t => <div key={t.id}><span>{dateText(t.occurredAt, timezone)}</span><strong title={t.action}>{TIMELINE_LABELS[t.action] || t.action}</strong><small>{t.actorUsername || "系统"}</small></div>)}</div></>}
</div>}
</Drawer>
<Dialog width="540px" visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "确认将该笔账目恢复为未报销状态?" : "确认该笔账目已完成报销审批与结算?"}</Dialog>
<Dialog width="540px" visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>移入回收站后将不在正常列表中展示,关联附件会完整保留,可随时前往回收站恢复。</Dialog>
<Dialog width="560px" visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "每笔账目至少需要保留一张有效的付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "当前为该账目唯一的发票附件,删除后请补充说明无发票原因。" : "确认删除该发票附件?"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
<Dialog width="880px" className="tn-dialog-xlarge" visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
</>;
}