"use client"; import { FormEvent, useMemo, useState } from "react"; import { Banknote, CircleDollarSign, Eye, FilePlus2, FileText, HandCoins, Plus, ReceiptText, Search, TriangleAlert, WalletCards, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog } from "@/components/ui/dialog"; import { Input, Textarea } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { Table } from "@/components/ui/table"; import { BILLING_STATUS_LABELS, BILLING_STATUS_VALUES, INVOICE_STATUS_LABELS, PAYMENT_METHOD_LABELS, PAYMENT_METHOD_VALUES, addBillingCharge, addBillingPayment, calculateBillingSummary, formatBillingPeriod, formatCny, toBillingStatementView, } from "@/modules/billing/types"; import type { BillingCharge, BillingStatement, BillingStatementView, BillingStatus, InvoiceStatus, PaymentMethod, } from "@/modules/billing/types"; type BillingWorkspaceClientProps = { initialStatements: BillingStatement[]; }; type PaymentFormState = { amountYuan: string; method: PaymentMethod; paidAt: string; reference: string; note: string; }; type ChargeFormState = { category: string; description: string; quantity: string; serviceDate: string; unitPriceYuan: string; }; type DialogState = | { kind: "detail"; statementId: string } | { kind: "payment"; statementId: string; form: PaymentFormState } | { kind: "charge"; statementId: string; form: ChargeFormState }; const STATUS_SORT_ORDER: Record = { overdue: 0, partial: 1, pending: 2, paid: 3, }; const chargeCategoryOptions = [ { value: "床位服务", label: "床位服务" }, { value: "护理服务", label: "护理服务" }, { value: "膳食服务", label: "膳食服务" }, { value: "健康管理", label: "健康管理" }, { value: "康复服务", label: "康复服务" }, { value: "生活用品", label: "生活用品" }, { value: "其他费用", label: "其他费用" }, ]; function formatDate(value: string): string { return new Date(value).toLocaleDateString("zh-CN"); } function formatDateTime(value: string): string { return new Date(value).toLocaleString("zh-CN"); } function toDateInputValue(date: Date): string { const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000); return localDate.toISOString().slice(0, 10); } function toDateTimeInputValue(date: Date): string { const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000); return localDate.toISOString().slice(0, 16); } function parseYuanToCents(value: string): number | undefined { const amount = Number(value); if (!Number.isFinite(amount) || amount <= 0) { return undefined; } return Math.round(amount * 100); } function getStatusVariant(status: BillingStatus): "danger" | "secondary" | "success" | "warning" { if (status === "paid") { return "success"; } if (status === "overdue") { return "danger"; } if (status === "partial") { return "warning"; } return "secondary"; } function getInvoiceVariant(status: InvoiceStatus): "secondary" | "success" | "warning" { if (status === "issued") { return "success"; } if (status === "pending") { return "warning"; } return "secondary"; } export function BillingWorkspaceClient({ initialStatements }: BillingWorkspaceClientProps): React.ReactElement { const [statements, setStatements] = useState(initialStatements); const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | BillingStatus>("all"); const [dialog, setDialog] = useState(); const [message, setMessage] = useState(""); const [referenceDate] = useState(() => new Date()); const statementViews = useMemo( () => statements .map((statement) => toBillingStatementView(statement, referenceDate)) .sort((left, right) => STATUS_SORT_ORDER[left.status] - STATUS_SORT_ORDER[right.status]), [referenceDate, statements], ); const summary = useMemo( () => calculateBillingSummary(statements, referenceDate), [referenceDate, statements], ); const normalizedQuery = query.trim().toLowerCase(); const filteredStatements = useMemo( () => statementViews.filter((statement) => { const matchesStatus = statusFilter === "all" || statement.status === statusFilter; const matchesQuery = !normalizedQuery || [ statement.id, statement.elderName, statement.roomLabel, statement.contactName, statement.contactPhone, ] .join(" ") .toLowerCase() .includes(normalizedQuery); return matchesStatus && matchesQuery; }), [normalizedQuery, statementViews, statusFilter], ); const selectedStatement = dialog ? statementViews.find((statement) => statement.id === dialog.statementId) : undefined; function openPayment(statement: BillingStatementView): void { setMessage(""); setDialog({ kind: "payment", statementId: statement.id, form: { amountYuan: (statement.outstandingCents / 100).toFixed(2), method: "wechat", paidAt: toDateTimeInputValue(new Date()), reference: "", note: "", }, }); } function openCharge(statement: BillingStatementView): void { setMessage(""); setDialog({ kind: "charge", statementId: statement.id, form: { category: "护理服务", description: "", quantity: "1", serviceDate: toDateInputValue(new Date()), unitPriceYuan: "", }, }); } function updatePaymentForm(next: Partial): void { setDialog((current) => current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current, ); } function updateChargeForm(next: Partial): void { setDialog((current) => current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current, ); } function submitPayment(event: FormEvent): void { event.preventDefault(); if (dialog?.kind !== "payment" || !selectedStatement) { return; } const amountCents = parseYuanToCents(dialog.form.amountYuan); if (!amountCents) { setMessage("请输入有效的收款金额"); return; } if (amountCents > selectedStatement.outstandingCents) { setMessage(`收款金额不能超过待收金额 ${formatCny(selectedStatement.outstandingCents)}`); return; } const paidAt = new Date(dialog.form.paidAt); if (Number.isNaN(paidAt.getTime())) { setMessage("请选择有效的收款时间"); return; } const statementId = selectedStatement.id; const payment = { id: `${statementId}-payment-${Date.now()}`, amountCents, method: dialog.form.method, paidAt: paidAt.toISOString(), reference: dialog.form.reference.trim() || `PAY${Date.now()}`, operator: "当前操作员", note: dialog.form.note.trim(), }; setStatements((current) => current.map((statement) => statement.id === statementId ? addBillingPayment(statement, payment) : statement, ), ); setDialog({ kind: "detail", statementId }); setMessage(`${selectedStatement.elderName} 收款 ${formatCny(amountCents)} 已登记`); } function submitCharge(event: FormEvent): void { event.preventDefault(); if (dialog?.kind !== "charge" || !selectedStatement) { return; } const quantity = Number(dialog.form.quantity); const unitPriceCents = parseYuanToCents(dialog.form.unitPriceYuan); if (!dialog.form.description.trim()) { setMessage("请输入费用项目说明"); return; } if (!Number.isFinite(quantity) || quantity <= 0) { setMessage("请输入有效的数量"); return; } if (!unitPriceCents) { setMessage("请输入有效的单价"); return; } const serviceDate = new Date(`${dialog.form.serviceDate}T12:00:00`); if (Number.isNaN(serviceDate.getTime())) { setMessage("请选择有效的服务日期"); return; } const statementId = selectedStatement.id; const charge: BillingCharge = { id: `${statementId}-charge-${Date.now()}`, category: dialog.form.category, description: dialog.form.description.trim(), quantity, serviceDate: serviceDate.toISOString(), unitPriceCents, }; setStatements((current) => current.map((statement) => statement.id === statementId ? addBillingCharge(statement, charge) : statement, ), ); setDialog({ kind: "detail", statementId }); setMessage(`${selectedStatement.elderName} 新增费用 ${formatCny(Math.round(quantity * unitPriceCents))}`); } function generateReceipt(statement: BillingStatementView): void { if (statement.payments.length === 0 || statement.receiptCount >= statement.payments.length) { setMessage("当前收款记录均已生成收据"); return; } setStatements((current) => current.map((item) => item.id === statement.id ? { ...item, receiptCount: item.payments.length } : item, ), ); setMessage(`${statement.elderName} 的最新收款收据已生成`); } function issueInvoice(statement: BillingStatementView): void { if (statement.outstandingCents > 0) { setMessage("账单结清后才能开具发票"); return; } if (statement.invoiceStatus === "issued") { setMessage(`发票 ${statement.invoiceNumber ?? ""} 已开具`); return; } const compactDate = new Date().toISOString().slice(0, 10).replaceAll("-", ""); const invoiceNumber = `FP${compactDate}${statement.id.slice(-3).toUpperCase()}`; setStatements((current) => current.map((item) => item.id === statement.id ? { ...item, invoiceStatus: "issued", invoiceNumber } : item, ), ); setMessage(`${statement.elderName} 的电子发票已开具:${invoiceNumber}`); } const dialogTitle = dialog?.kind === "payment" ? "登记收款" : dialog?.kind === "charge" ? "登记费用" : "账单详情"; return (

费用管理

统一查看老人月度账单、费用项目、收款记录和票据状态。

onUpdate({ amountYuan: event.target.value })} required step="0.01" type="number" value={form.amountYuan} /> onUpdate({ paidAt: event.target.value })} required type="datetime-local" value={form.paidAt} /> onUpdate({ reference: event.target.value })} placeholder="留空后自动生成" value={form.reference} />