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

170 lines
17 KiB
TypeScript

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<File[]>([]); const [invoices, setInvoices] = useState<File[]>([]); 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<Expense | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<ExpenseField, string>>>({});
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<HTMLInputElement>(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<Record<ExpenseField, string>> = {};
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<Record<ExpenseField, string>>;
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 <>
<Drawer className="tn-content-drawer tn-expense-drawer" placement="right" size="480px" visible destroyOnClose header={expense ? "编辑账目" : "新增账目"} onClose={closeDrawer} footer={<><Button variant="outline" onClick={() => requestDiscard(onClose)} disabled={busy}>取消</Button><Button theme="primary" onClick={() => { void submit({ preventDefault() {} } as React.FormEvent); }} disabled={busy}>{busy ? <Loader2 size={16} className="spin" /> : <><Check size={16} />保存</>}</Button></>}>
<form className="expense-form" onSubmit={submit}>
<label className="tn-form-field"><span>支付时间</span><DatePicker disabled={busy} inputProps={{ "aria-label": "支付时间", "aria-invalid": Boolean(fieldErrors.paidAt), "aria-describedby": fieldErrors.paidAt ? "expense-paid-at-error" : undefined } as any} popupProps={{ onVisibleChange: (visible: boolean) => setDatePickerOpen(visible) }} enableTimePicker format="YYYY-MM-DD HH:mm" value={paidAt} onChange={(v: any) => { setPaidAt(String(v)); clearField("paidAt"); }} /><small>选择日期和时间(精确到分钟,按应用时区保存)</small><FieldError id="expense-paid-at-error" message={fieldErrors.paidAt} /></label>
<label className="tn-form-field"><span>金额(元)</span><AccessibleInput disabled={busy} inputAriaLabel="金额(元)" inputAriaInvalid={Boolean(fieldErrors.amount)} inputAriaDescribedby={fieldErrors.amount ? "expense-amount-error" : undefined} value={amount} onChange={value => { setAmount(value); clearField("amount"); }} placeholder="0.00" /><FieldError id="expense-amount-error" message={fieldErrors.amount} /></label>
<label className="tn-form-field"><span>备注</span><Textarea disabled={busy} aria-label="备注" aria-invalid={Boolean(fieldErrors.note)} aria-describedby={fieldErrors.note ? "expense-note-error" : undefined} value={note} onChange={value => { setNote(value); clearField("note"); }} maxlength={2000} placeholder="可选" /><FieldError id="expense-note-error" message={fieldErrors.note} /></label>
<div className="tn-form-field" role="group" aria-labelledby="expense-payment-proofs-label"><span id="expense-payment-proofs-label">付款凭证(至少 1 张)</span><Upload disabled={busy} className="tn-payment-proof-upload" aria-label="选择付款凭证文件" aria-labelledby="expense-payment-proofs-label" multiple autoUpload={false} accept="image/jpeg,image/png,image/webp" onChange={choose("proof") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.paymentProofs)} aria-describedby={fieldErrors.paymentProofs ? "expense-proof-error" : undefined} icon={<FileUp size={15} />}>选择付款凭证</Button>} /><FieldError id="expense-proof-error" message={fieldErrors.paymentProofs} /></div>
{proofs.length > 0 && <FileList files={proofs} disabled={busy} remove={f => setProofs(proofs.filter(x => x !== f))} />}
<div className="tn-form-field" role="group" aria-labelledby="expense-invoices-label"><span id="expense-invoices-label">发票(可选)</span><Upload disabled={busy} className="tn-invoice-upload" aria-label="选择发票文件" aria-labelledby="expense-invoices-label" multiple autoUpload={false} accept=".pdf,.ofd,.xml,application/pdf,application/ofd,application/xml" onChange={choose("invoice") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.invoices)} aria-describedby={fieldErrors.invoices ? "expense-invoice-error" : undefined} icon={<FileUp size={15} />}>选择发票</Button>} /><FieldError id="expense-invoice-error" message={fieldErrors.invoices} /></div>
{invoices.length > 0 && <FileList files={invoices} disabled={busy} remove={f => setInvoices(invoices.filter(x => x !== f))} />}
<Checkbox className="invoice-missing-toggle" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} checked={missing} disabled={busy || hasInvoice} onChange={(checked: boolean) => { setMissing(checked); clearField("invoiceMissingReason"); if (!checked) setReason(""); }}>无发票</Checkbox>
{!hasInvoice && !missing && (fieldErrors.invoiceMissingReason ? <small id="expense-reason-error" className="tn-field-error" role="alert">{fieldErrors.invoiceMissingReason}</small> : <small className="tn-form-help">请上传发票,或勾选“无发票”并填写原因。</small>)}
{hasInvoice && <small>已上传发票,无需填写无发票原因。</small>}
{missing && !hasInvoice && <label className="tn-form-field"><span>无发票原因</span><Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} value={reason} onChange={value => { setReason(value); clearField("invoiceMissingReason"); }} maxlength={500} placeholder="例如:商家无法开具发票" /><FieldError id="expense-reason-error" message={fieldErrors.invoiceMissingReason} /></label>}
{error && <div role="alert" className="expense-error"><AlertCircle size={16} />{error}</div>}
</form>
</Drawer>
<Dialog width="560px" visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("已取消冲突提示,再次点击保存将重新确认最新数据。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("已保留您当前编辑的内容,再次点击保存将更新此账目。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>此笔账目已被其他管理员更新。为保障财务数据准确,请选择保留您当前的编辑并覆盖,或同步加载最新版本。</Dialog>
</>;
}
function focusExpenseField(errors: Partial<Record<ExpenseField, string>>) {
const selectors: Partial<Record<ExpenseField, string>> = {
paidAt: "[aria-describedby='expense-paid-at-error'], [aria-describedby='expense-paid-at-error'] input",
amount: "[aria-describedby='expense-amount-error'] input, [aria-describedby='expense-amount-error']",
note: "[aria-describedby='expense-note-error'] textarea, [aria-describedby='expense-note-error']",
paymentProofs: "[aria-describedby='expense-proof-error']",
invoices: "[aria-describedby='expense-invoice-error']",
invoiceMissingReason: "[aria-describedby='expense-reason-error'] textarea, [aria-describedby='expense-reason-error'], .invoice-missing-toggle",
};
const field = (["paidAt", "amount", "note", "paymentProofs", "invoices", "invoiceMissingReason"] as ExpenseField[]).find(key => errors[key]);
if (!field) return;
requestAnimationFrame(() => {
const node = document.querySelector<HTMLElement>(selectors[field] || "");
const target = node && /^(INPUT|TEXTAREA|BUTTON|SELECT)$/.test(node.tagName)
? node
: node?.querySelector<HTMLElement>("input, textarea, button, select");
target?.focus();
});
}
function FieldError({ id, message }: { id: string; message?: string }) { return message ? <small id={id} className="tn-field-error" role="alert">{message}</small> : null; }
function FileList({ files, remove, disabled = false }: { files: File[]; remove: (f: File) => void; disabled?: boolean }) { return <ul className="expense-file-list">{files.map(f => <li key={`${f.name}-${f.size}-${f.lastModified}`}><FileUp size={14} />{f.name}<small>{formatBytes(f.size)}</small><Tooltip content="移除文件" placement="left"><Button variant="text" shape="circle" disabled={disabled} onClick={() => remove(f)} aria-label={`移除文件 ${f.name}`}><X size={14} /></Button></Tooltip></li>)}</ul>; }