73 lines
11 KiB
TypeScript
73 lines
11 KiB
TypeScript
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>
|
||
</>;
|
||
}
|