import React, { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Check, FileUp, Loader2, X } from "lucide-react"; import { Button, Checkbox, DatePicker, Dialog, Drawer, Textarea, Tooltip, Upload } from "tdesign-react"; import AccessibleInput from "../../components/AccessibleInput"; import { api, ApiError } from "../../services/api"; import { apiFieldErrors } from "../../services/formErrors"; import { dateFromInput, dateInputValue, formatBytes } from "./date"; import type { Expense, Notify } from "./types"; import { useUnsavedActions, useUnsavedChanges } from "../../contexts/UnsavedChanges"; import { useDrawerFocus } from "../../hooks/useDrawerFocus"; type Props = { expense?: Expense | null; timezone?: string; onClose: () => void; onSaved: () => void; notify?: Notify }; type UploadFile = File & { raw?: File }; type ExpenseField = "paidAt" | "amount" | "note" | "paymentProofs" | "invoices" | "invoiceMissingReason"; export default function ExpenseDrawer({ expense, timezone = "Asia/Shanghai", onClose, onSaved, notify }: Props) { const initial = useMemo(() => ({ amount: expense ? (expense.amountCents / 100).toFixed(2) : "", note: expense?.note || "", paidAt: expense ? dateInputValue(new Date(expense.paidAt), timezone) : dateInputValue(new Date(), timezone), reason: expense?.invoiceMissingReason || "" }), [expense, timezone]); const [amount, setAmount] = useState(initial.amount); const [note, setNote] = useState(initial.note); const [paidAt, setPaidAt] = useState(initial.paidAt); const [proofs, setProofs] = useState([]); const [invoices, setInvoices] = useState([]); const [missing, setMissing] = useState(Boolean(initial.reason)); const [reason, setReason] = useState(initial.reason); const [serverInvoiceCount, setServerInvoiceCount] = useState(expense?.invoiceCount || 0); const [version, setVersion] = useState(expense?.version || 1); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [conflict, setConflict] = useState(null); const [fieldErrors, setFieldErrors] = useState>>({}); const [datePickerOpen, setDatePickerOpen] = useState(false); const submitInFlight = useRef(false); useDrawerFocus(true, ".tn-expense-drawer"); useEffect(() => { setAmount(initial.amount); setNote(initial.note); setPaidAt(initial.paidAt); setReason(initial.reason); setMissing(Boolean(initial.reason)); setServerInvoiceCount(expense?.invoiceCount || 0); setVersion(expense?.version || 1); setProofs([]); setInvoices([]); }, [initial, expense]); // The drawer is mounted through a portal; explicitly hydrate from the record // identity as well so a portal reuse cannot leave stale blank controls. useEffect(() => { if (!expense) return; setAmount((expense.amountCents / 100).toFixed(2)); setNote(expense.note || ""); setPaidAt(dateInputValue(new Date(expense.paidAt), timezone)); setReason(expense.invoiceMissingReason || ""); setMissing(Boolean(expense.invoiceMissingReason)); setServerInvoiceCount(expense.invoiceCount || 0); setVersion(expense.version || 1); }, [expense?.id, expense?.amountCents, expense?.paidAt, expense?.note, expense?.invoiceMissingReason, expense?.invoiceCount, expense?.version, timezone]); useEffect(() => { // TDesign keeps its native file input hidden and does not forward ARIA // props to it. Label both inputs explicitly for assistive technology; // the visible trigger buttons remain the primary keyboard targets. const inputs: Array<[string, string, string, string]> = [ [".tn-payment-proof-upload input[type=file]", "expense-payment-proofs-input", "expense-payment-proofs-label", "付款凭证文件"], [".tn-invoice-upload input[type=file]", "expense-invoices-input", "expense-invoices-label", "发票文件"], ]; const applyLabels = () => inputs.forEach(([selector, id, labelledBy, label]) => { const input = document.querySelector(selector); if (!input) return; input.id = id; input.setAttribute("aria-labelledby", labelledBy); input.setAttribute("aria-label", label); }); // Drawer content is portalled after the first effect pass. Retry on the // next frame and once after the opening transition so the native inputs // receive their labels in both mounted and animated states. applyLabels(); const frame = window.requestAnimationFrame(applyLabels); const timer = window.setTimeout(applyLabels, 260); return () => { window.cancelAnimationFrame(frame); window.clearTimeout(timer); }; }, []); const hasInvoice = serverInvoiceCount + invoices.length > 0; const dirty = proofs.length > 0 || invoices.length > 0 || amount !== initial.amount || note !== initial.note || paidAt !== initial.paidAt || reason !== initial.reason || missing !== Boolean(initial.reason); const { requestDiscard } = useUnsavedActions(); useUnsavedChanges(dirty); const clearField = (...fields: ExpenseField[]) => { setFieldErrors(current => { const next = { ...current }; fields.forEach(field => delete next[field]); return next; }); setError(""); }; const choose = (kind: "proof" | "invoice") => (files: any[]) => { clearField(kind === "proof" ? "paymentProofs" : "invoices", "invoiceMissingReason"); const next = files.map((f: any) => (f.raw || f.originFileObj || f) as UploadFile).filter((f: any) => f instanceof File || f?.name); if (kind === "proof") setProofs(next.slice(0, Math.max(0, 20 - invoices.length))); else { setInvoices(next.slice(0, Math.max(0, 20 - proofs.length))); if (next.length) { setMissing(false); setReason(""); } } }; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (busy || submitInFlight.current) return; setError(""); const nextErrors: Partial> = {}; if (!expense && !proofs.length) nextErrors.paymentProofs = "至少选择一张付款凭证"; if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(amount) || Number(amount) <= 0) nextErrors.amount = "金额必须为大于零且最多两位小数"; let iso = ""; try { iso = dateFromInput(paidAt, timezone); } catch (caught) { nextErrors.paidAt = (caught as Error).message; } const normalized = missing ? reason.trim() : ""; if (!hasInvoice && !normalized) nextErrors.invoiceMissingReason = "请上传发票,或勾选“无发票”并填写原因"; if (Object.keys(nextErrors).length) { setFieldErrors(nextErrors); setError("请检查标记的字段后再保存"); focusExpenseField(nextErrors); return; } setFieldErrors({}); submitInFlight.current = true; setBusy(true); try { if (expense) { // A multipart edit carries the fields and all new attachments in one // optimistic-lock transaction. This prevents partial edits when one // upload or a later validation fails. const body = new FormData(); body.append("paidAt", iso); body.append("amount", amount); body.append("note", note); body.append("invoiceMissingReason", hasInvoice ? "" : normalized); body.append("version", String(version)); proofs.forEach(file => body.append("paymentProofs", file)); invoices.forEach(file => body.append("invoices", file)); const updated = await api<{ expense: Expense }>(`/api/expenses/${expense.id}`, { method: "PATCH", body }); setVersion(updated.expense.version); setServerInvoiceCount(updated.expense.invoiceCount); } else { const body = new FormData(); body.append("paidAt", iso); body.append("amount", amount); body.append("note", note); body.append("invoiceMissingReason", normalized); proofs.forEach(f => body.append("paymentProofs", f)); invoices.forEach(f => body.append("invoices", f)); await api("/api/expenses", { method: "POST", body }); } notify?.(expense ? "账目已更新" : "账目已保存", "success"); onSaved(); onClose(); } catch (caught) { if (caught instanceof ApiError && caught.status === 409 && (caught as any).details?.current) setConflict((caught as any).details.current); else { const mapped = apiFieldErrors(caught) as Partial>; if (Object.keys(mapped).length) { setFieldErrors(mapped); setError("请检查标记的字段后再保存"); focusExpenseField(mapped); } else setError((caught as Error).message); } } finally { submitInFlight.current = false; setBusy(false); } }; const closeDrawer = (context?: { trigger?: string }) => { // ESC first belongs to the nested date/time popup. TDesign bubbles the // same key to Drawer, so consume that close event while the popup is open. if (context?.trigger === "esc" && datePickerOpen) { setDatePickerOpen(false); return; } requestDiscard(onClose); }; return <> }>