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 = { "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([]); 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(null); const [removeReason, setRemoveReason] = useState(""); const [preview, setPreview] = useState(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 <> }> {loading ?
: error ?
{error}
:
{money(detail.amountCents)}
支付时间
{dateText(detail.paidAt, timezone)}
发票
{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <>无发票:{detail.invoiceMissingReason} : 未说明}
状态
{detail.status === "reimbursed" ? "已报销" : "未报销"}
备注
{detail.note || "无"}

附件 {detail.attachments?.length || 0}

{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return
{a.mimeType.startsWith("image/") ? : } {a.originalName}{formatBytes(a.sizeBytes)}{a.previewable && }
; })}
{timeline.length > 0 && <>

操作记录

{timeline.slice(0, 12).map(t =>
{dateText(t.occurredAt, timezone)}{TIMELINE_LABELS[t.action] || t.action}{t.actorUsername || "系统"}
)}
}
}
{ if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"} { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。 { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <>

{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}

{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() &&