feat: add fee workspace and polish AI presentation

This commit is contained in:
2026-07-09 17:05:19 -07:00
parent 3aabdf0c4a
commit ef4dd21d32
19 changed files with 1589 additions and 15 deletions

View File

@@ -0,0 +1,832 @@
"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<BillingStatus, number> = {
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<DialogState>();
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<PaymentFormState>): void {
setDialog((current) =>
current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function updateChargeForm(next: Partial<ChargeFormState>): void {
setDialog((current) =>
current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function submitPayment(event: FormEvent<HTMLFormElement>): 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<HTMLFormElement>): 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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CircleDollarSign} label="本月应收" value={formatCny(summary.receivableCents)} />
<MetricCard icon={HandCoins} label="本月已收" value={formatCny(summary.collectedCents)} />
<MetricCard icon={WalletCards} label="待收金额" value={formatCny(summary.outstandingCents)} />
<MetricCard icon={TriangleAlert} label="逾期账户" value={`${summary.overdueCount}`} tone="danger" />
</section>
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[34rem]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="搜索费用账单"
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、房间、联系人或账单号"
value={query}
/>
</label>
<Select
aria-label="账单状态"
onValueChange={(value) => setStatusFilter(value as "all" | BillingStatus)}
options={[
{ value: "all", label: "全部账单状态" },
...BILLING_STATUS_VALUES.map((status) => ({ value: status, label: BILLING_STATUS_LABELS[status] })),
]}
value={statusFilter}
/>
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<Card>
<CardHeader className="gap-1">
<CardTitle></CardTitle>
<CardDescription> {filteredStatements.length} </CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[1040px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"> / </Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredStatements.map((statement) => (
<Table.Row key={statement.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{statement.elderName}</p>
<p className="text-xs text-muted-foreground">
{formatBillingPeriod(statement.period)} / {statement.id.toUpperCase()}
</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{statement.roomLabel}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.totalCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums text-muted-foreground">{formatCny(statement.paidCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.outstandingCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(statement.dueAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getStatusVariant(statement.status)}>{BILLING_STATUS_LABELS[statement.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getInvoiceVariant(statement.invoiceStatus)}>
{INVOICE_STATUS_LABELS[statement.invoiceStatus]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
aria-label={`查看 ${statement.elderName} 账单`}
onClick={() => setDialog({ kind: "detail", statementId: statement.id })}
size="icon"
title="查看账单"
type="button"
variant="outline"
>
<Eye className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 费用`}
onClick={() => openCharge(statement)}
size="icon"
title="登记费用"
type="button"
variant="outline"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 收款`}
disabled={statement.outstandingCents === 0}
onClick={() => openPayment(statement)}
size="icon"
title="登记收款"
type="button"
>
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredStatements.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-10 text-center text-muted-foreground" colSpan={9}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog
description={selectedStatement ? `${selectedStatement.elderName} / ${formatBillingPeriod(selectedStatement.period)}` : undefined}
onClose={() => setDialog(undefined)}
open={dialog !== undefined && selectedStatement !== undefined}
title={dialogTitle}
width={dialog?.kind === "detail" ? "wide" : "lg"}
>
{dialog?.kind === "detail" && selectedStatement ? (
<StatementDetail
onCharge={() => openCharge(selectedStatement)}
onInvoice={() => issueInvoice(selectedStatement)}
onPayment={() => openPayment(selectedStatement)}
onReceipt={() => generateReceipt(selectedStatement)}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "payment" && selectedStatement ? (
<PaymentForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitPayment}
onUpdate={updatePaymentForm}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "charge" && selectedStatement ? (
<ChargeForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitCharge}
onUpdate={updateChargeForm}
/>
) : null}
</Dialog>
</div>
);
}
function StatementDetail({
onCharge,
onInvoice,
onPayment,
onReceipt,
statement,
}: {
onCharge: () => void;
onInvoice: () => void;
onPayment: () => void;
onReceipt: () => void;
statement: BillingStatementView;
}): React.ReactElement {
const canGenerateReceipt = statement.payments.length > statement.receiptCount;
const canIssueInvoice = statement.outstandingCents === 0 && statement.invoiceStatus !== "issued";
return (
<div className="grid gap-5">
<section className="grid gap-4 border-b pb-5 sm:grid-cols-2 lg:grid-cols-4">
<SummaryItem label="应收金额" value={formatCny(statement.totalCents)} />
<SummaryItem label="已收金额" value={formatCny(statement.paidCents)} />
<SummaryItem label="待收金额" value={formatCny(statement.outstandingCents)} />
<div>
<p className="text-xs text-muted-foreground"></p>
<Badge className="mt-2" variant={getStatusVariant(statement.status)}>
{BILLING_STATUS_LABELS[statement.status]}
</Badge>
</div>
</section>
<section className="grid gap-3 rounded-md border bg-secondary/25 p-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoItem label="房间床位" value={statement.roomLabel} />
<InfoItem label="缴费联系人" value={statement.contactName} />
<InfoItem label="联系电话" value={statement.contactPhone} />
<InfoItem label="到期日期" value={formatDate(statement.dueAt)} />
</section>
<section className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold"></h3>
<Button onClick={onCharge} size="sm" type="button" variant="outline">
<FilePlus2 className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.charges.map((charge) => (
<Table.Row key={charge.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{charge.description}</p>
<p className="text-xs text-muted-foreground">{charge.category}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(charge.serviceDate)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{charge.quantity}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{formatCny(charge.unitPriceCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">
{formatCny(Math.round(charge.quantity * charge.unitPriceCents))}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</section>
<section className="grid gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="mt-1 text-xs text-muted-foreground">
{statement.receiptCount}/{statement.payments.length} · {INVOICE_STATUS_LABELS[statement.invoiceStatus]}
{statement.invoiceNumber ? ` / ${statement.invoiceNumber}` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={statement.outstandingCents === 0} onClick={onPayment} size="sm" type="button">
<Banknote className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canGenerateReceipt} onClick={onReceipt} size="sm" type="button" variant="outline">
<ReceiptText className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canIssueInvoice} onClick={onInvoice} size="sm" type="button" variant="outline">
<FileText className="size-4" aria-hidden="true" />
{statement.invoiceStatus === "issued" ? "发票已开具" : "开具发票"}
</Button>
</div>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[760px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.payments.map((payment, index) => (
<Table.Row key={payment.id}>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(payment.paidAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">{PAYMENT_METHOD_LABELS[payment.method]}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.reference}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.operator}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={index < statement.receiptCount ? "success" : "secondary"}>
{index < statement.receiptCount ? "已生成" : "待生成"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">{formatCny(payment.amountCents)}</Table.Cell>
</Table.Row>
))}
{statement.payments.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</section>
</div>
);
}
function PaymentForm({
form,
onCancel,
onSubmit,
onUpdate,
statement,
}: {
form: PaymentFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<PaymentFormState>) => void;
statement: BillingStatementView;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="rounded-md border bg-secondary/25 p-4">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatCny(statement.outstandingCents)}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="收款金额(元)"
max={(statement.outstandingCents / 100).toFixed(2)}
min="0.01"
onChange={(event) => onUpdate({ amountYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.amountYuan}
/>
<Select
label="收款方式"
onValueChange={(value) => onUpdate({ method: value as PaymentMethod })}
options={PAYMENT_METHOD_VALUES.map((method) => ({ value: method, label: PAYMENT_METHOD_LABELS[method] }))}
value={form.method}
/>
<Input
label="收款时间"
onChange={(event) => onUpdate({ paidAt: event.target.value })}
required
type="datetime-local"
value={form.paidAt}
/>
<Input
label="支付流水号"
onChange={(event) => onUpdate({ reference: event.target.value })}
placeholder="留空后自动生成"
value={form.reference}
/>
</div>
<Textarea
label="备注"
onChange={(event) => onUpdate({ note: event.target.value })}
placeholder="填写付款人或对账说明"
value={form.note}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function ChargeForm({
form,
onCancel,
onSubmit,
onUpdate,
}: {
form: ChargeFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<ChargeFormState>) => void;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="grid gap-3 sm:grid-cols-2">
<Select
label="费用分类"
onValueChange={(value) => onUpdate({ category: value })}
options={chargeCategoryOptions}
value={form.category}
/>
<Input
label="服务日期"
onChange={(event) => onUpdate({ serviceDate: event.target.value })}
required
type="date"
value={form.serviceDate}
/>
</div>
<Input
label="费用项目说明"
onChange={(event) => onUpdate({ description: event.target.value })}
placeholder="例如:临时陪诊服务"
required
value={form.description}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="数量"
min="0.01"
onChange={(event) => onUpdate({ quantity: event.target.value })}
required
step="0.01"
type="number"
value={form.quantity}
/>
<Input
label="单价(元)"
min="0.01"
onChange={(event) => onUpdate({ unitPriceYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.unitPriceYuan}
/>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function MetricCard({
icon: Icon,
label,
tone = "default",
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
tone?: "danger" | "default";
value: string;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<CardTitle className="mt-2 truncate text-2xl tabular-nums">{value}</CardTitle>
</div>
<Icon className={tone === "danger" ? "size-5 shrink-0 text-destructive" : "size-5 shrink-0 text-primary"} aria-hidden={true} />
</CardHeader>
</Card>
);
}
function SummaryItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-sm font-medium">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,212 @@
import type { BillingCharge, BillingPayment, BillingStatement, PaymentMethod } from "@/modules/billing/types";
type StatementSeed = {
id: string;
elderId: string;
elderName: string;
roomLabel: string;
contactName: string;
contactPhone: string;
dueOffsetDays: number;
charges: Array<Omit<BillingCharge, "id" | "serviceDate">>;
payments: Array<{
amountCents: number;
method: PaymentMethod;
paidOffsetDays: number;
reference: string;
operator: string;
note: string;
}>;
invoiceStatus?: BillingStatement["invoiceStatus"];
invoiceNumber?: string;
receiptCount?: number;
};
const STATEMENT_SEEDS: StatementSeed[] = [
{
id: "bill-wgl",
elderId: "elder-wgl",
elderName: "王桂兰",
roomLabel: "A101-1",
contactName: "张敏",
contactPhone: "13800000001",
dueOffsetDays: 6,
charges: [
{ category: "床位服务", description: "标准护理房床位费", quantity: 1, unitPriceCents: 360000 },
{ category: "护理服务", description: "重点照护服务包", quantity: 1, unitPriceCents: 390000 },
{ category: "膳食服务", description: "低盐营养膳食", quantity: 1, unitPriceCents: 180000 },
{ category: "健康管理", description: "血压跟踪与健康复核", quantity: 1, unitPriceCents: 30000 },
],
payments: [
{
amountCents: 960000,
method: "wechat",
paidOffsetDays: -2,
reference: "WX202607090018",
operator: "前台收费组",
note: "家属线上支付",
},
],
invoiceStatus: "issued",
invoiceNumber: "FP20260709001",
receiptCount: 1,
},
{
id: "bill-ljg",
elderId: "elder-ljg",
elderName: "李建国",
roomLabel: "A102-1",
contactName: "李明",
contactPhone: "13800000002",
dueOffsetDays: 8,
charges: [
{ category: "床位服务", description: "半护理房床位费", quantity: 1, unitPriceCents: 320000 },
{ category: "护理服务", description: "半护理服务包", quantity: 1, unitPriceCents: 250000 },
{ category: "膳食服务", description: "日常营养膳食", quantity: 1, unitPriceCents: 170000 },
{ category: "活动服务", description: "康体活动与陪同", quantity: 1, unitPriceCents: 40000 },
],
payments: [
{
amountCents: 400000,
method: "bank_transfer",
paidOffsetDays: -1,
reference: "BANK20260708032",
operator: "财务值班",
note: "首笔转账已确认",
},
],
invoiceStatus: "pending",
},
{
id: "bill-zyz",
elderId: "elder-zyz",
elderName: "周玉珍",
roomLabel: "A201-1",
contactName: "周强",
contactPhone: "13800000003",
dueOffsetDays: 10,
charges: [
{ category: "床位服务", description: "记忆照护专区床位费", quantity: 1, unitPriceCents: 370000 },
{ category: "护理服务", description: "协助护理服务包", quantity: 1, unitPriceCents: 285000 },
{ category: "膳食服务", description: "糖尿病营养膳食", quantity: 1, unitPriceCents: 185000 },
],
payments: [],
},
{
id: "bill-zgh",
elderId: "elder-zgh",
elderName: "赵国华",
roomLabel: "A202-1",
contactName: "赵琳",
contactPhone: "13800000004",
dueOffsetDays: -3,
charges: [
{ category: "床位服务", description: "重点护理房床位费", quantity: 1, unitPriceCents: 380000 },
{ category: "护理服务", description: "失能照护服务包", quantity: 1, unitPriceCents: 420000 },
{ category: "膳食服务", description: "软食营养膳食", quantity: 1, unitPriceCents: 185000 },
{ category: "生活用品", description: "护理耗材包", quantity: 1, unitPriceCents: 60000 },
],
payments: [
{
amountCents: 200000,
method: "cash",
paidOffsetDays: -6,
reference: "CASH20260703009",
operator: "前台收费组",
note: "现金预缴",
},
],
},
{
id: "bill-msf",
elderId: "elder-msf",
elderName: "马淑芬",
roomLabel: "A301-1",
contactName: "马晨",
contactPhone: "13800000011",
dueOffsetDays: -5,
charges: [
{ category: "床位服务", description: "失能照护区床位费", quantity: 1, unitPriceCents: 390000 },
{ category: "护理服务", description: "全护理服务包", quantity: 1, unitPriceCents: 460000 },
{ category: "膳食服务", description: "吞咽困难软食套餐", quantity: 1, unitPriceCents: 205000 },
{ category: "营养服务", description: "营养师评估与加餐", quantity: 1, unitPriceCents: 65000 },
],
payments: [],
},
{
id: "bill-qsb",
elderId: "elder-qsb",
elderName: "钱松柏",
roomLabel: "S101-1",
contactName: "钱宁",
contactPhone: "13800000014",
dueOffsetDays: 4,
charges: [
{ category: "床位服务", description: "医养观察房床位费", quantity: 1, unitPriceCents: 430000 },
{ category: "护理服务", description: "医养重点照护服务包", quantity: 1, unitPriceCents: 480000 },
{ category: "膳食服务", description: "呼吸慢病营养膳食", quantity: 1, unitPriceCents: 190000 },
{ category: "设备服务", description: "制氧与血氧监测服务", quantity: 1, unitPriceCents: 150000 },
],
payments: [
{
amountCents: 1250000,
method: "card",
paidOffsetDays: -1,
reference: "POS20260708017",
operator: "医养前台",
note: "银行卡支付",
},
],
receiptCount: 1,
},
];
function addDays(reference: Date, days: number): string {
const result = new Date(reference);
result.setDate(result.getDate() + days);
return result.toISOString();
}
function getPeriod(reference: Date): string {
const year = reference.getFullYear();
const month = String(reference.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function createCharges(seed: StatementSeed, reference: Date): BillingCharge[] {
return seed.charges.map((charge, index) => ({
...charge,
id: `${seed.id}-charge-${index + 1}`,
serviceDate: addDays(reference, -Math.max(1, 12 - index * 2)),
}));
}
function createPayments(seed: StatementSeed, reference: Date): BillingPayment[] {
return seed.payments.map((payment, index) => ({
id: `${seed.id}-payment-${index + 1}`,
amountCents: payment.amountCents,
method: payment.method,
paidAt: addDays(reference, payment.paidOffsetDays),
reference: payment.reference,
operator: payment.operator,
note: payment.note,
}));
}
export function createInitialBillingStatements(referenceDate = new Date()): BillingStatement[] {
return STATEMENT_SEEDS.map((seed) => ({
id: seed.id,
elderId: seed.elderId,
elderName: seed.elderName,
roomLabel: seed.roomLabel,
contactName: seed.contactName,
contactPhone: seed.contactPhone,
period: getPeriod(referenceDate),
dueAt: addDays(referenceDate, seed.dueOffsetDays),
charges: createCharges(seed, referenceDate),
payments: createPayments(seed, referenceDate),
invoiceStatus: seed.invoiceStatus ?? "not_requested",
invoiceNumber: seed.invoiceNumber,
receiptCount: seed.receiptCount ?? 0,
}));
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
import {
addBillingCharge,
addBillingPayment,
calculateBillingSummary,
getBillingStatus,
getStatementOutstandingCents,
getStatementTotalCents,
} from "@/modules/billing/types";
const referenceDate = new Date("2026-07-09T12:00:00.000Z");
describe("billing calculations", () => {
it("derives paid, partial, pending, and overdue states from ledger activity", () => {
const statements = createInitialBillingStatements(referenceDate);
expect(statements.map((statement) => getBillingStatus(statement, referenceDate))).toEqual([
"paid",
"partial",
"pending",
"overdue",
"overdue",
"paid",
]);
});
it("calculates statement and workspace totals from charges and payments", () => {
const statements = createInitialBillingStatements(referenceDate);
const summary = calculateBillingSummary(statements, referenceDate);
const firstStatement = statements[0];
expect(firstStatement).toBeDefined();
if (!firstStatement) {
return;
}
expect(getStatementTotalCents(firstStatement)).toBe(960000);
expect(summary.receivableCents).toBe(5995000);
expect(summary.collectedCents).toBe(2810000);
expect(summary.outstandingCents).toBe(3185000);
expect(summary.overdueCount).toBe(2);
});
it("updates outstanding amounts when payments and charges are appended", () => {
const statement = createInitialBillingStatements(referenceDate)[2];
expect(statement).toBeDefined();
if (!statement) {
return;
}
const paidStatement = addBillingPayment(statement, {
id: "payment-new",
amountCents: 200000,
method: "wechat",
paidAt: referenceDate.toISOString(),
reference: "WX-NEW",
operator: "收费员",
note: "",
});
expect(getStatementOutstandingCents(paidStatement)).toBe(640000);
expect(getBillingStatus(paidStatement, referenceDate)).toBe("partial");
const chargedStatement = addBillingCharge(paidStatement, {
id: "charge-new",
category: "生活用品",
description: "护理耗材",
quantity: 2,
serviceDate: referenceDate.toISOString(),
unitPriceCents: 5000,
});
expect(getStatementOutstandingCents(chargedStatement)).toBe(650000);
expect(chargedStatement.invoiceStatus).toBe("not_requested");
});
});

169
modules/billing/types.ts Normal file
View File

@@ -0,0 +1,169 @@
export const BILLING_STATUS_VALUES = ["paid", "partial", "pending", "overdue"] as const;
export type BillingStatus = (typeof BILLING_STATUS_VALUES)[number];
export const BILLING_STATUS_LABELS: Record<BillingStatus, string> = {
paid: "已结清",
partial: "部分收款",
pending: "待收款",
overdue: "已逾期",
};
export const PAYMENT_METHOD_VALUES = ["wechat", "alipay", "bank_transfer", "card", "cash"] as const;
export type PaymentMethod = (typeof PAYMENT_METHOD_VALUES)[number];
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
wechat: "微信支付",
alipay: "支付宝",
bank_transfer: "银行转账",
card: "银行卡",
cash: "现金",
};
export const INVOICE_STATUS_VALUES = ["not_requested", "pending", "issued"] as const;
export type InvoiceStatus = (typeof INVOICE_STATUS_VALUES)[number];
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
not_requested: "未开票",
pending: "待开票",
issued: "已开票",
};
export type BillingCharge = {
id: string;
category: string;
description: string;
quantity: number;
serviceDate: string;
unitPriceCents: number;
};
export type BillingPayment = {
id: string;
amountCents: number;
method: PaymentMethod;
paidAt: string;
reference: string;
operator: string;
note: string;
};
export type BillingStatement = {
id: string;
elderId: string;
elderName: string;
roomLabel: string;
contactName: string;
contactPhone: string;
period: string;
dueAt: string;
charges: BillingCharge[];
payments: BillingPayment[];
invoiceStatus: InvoiceStatus;
invoiceNumber?: string;
receiptCount: number;
};
export type BillingStatementView = BillingStatement & {
totalCents: number;
paidCents: number;
outstandingCents: number;
status: BillingStatus;
};
export type BillingSummary = {
receivableCents: number;
collectedCents: number;
outstandingCents: number;
overdueCount: number;
};
export function getChargeAmountCents(charge: BillingCharge): number {
return Math.round(charge.quantity * charge.unitPriceCents);
}
export function getStatementTotalCents(statement: BillingStatement): number {
return statement.charges.reduce((total, charge) => total + getChargeAmountCents(charge), 0);
}
export function getStatementPaidCents(statement: BillingStatement): number {
return statement.payments.reduce((total, payment) => total + payment.amountCents, 0);
}
export function getStatementOutstandingCents(statement: BillingStatement): number {
return Math.max(0, getStatementTotalCents(statement) - getStatementPaidCents(statement));
}
export function getBillingStatus(statement: BillingStatement, referenceDate = new Date()): BillingStatus {
const outstandingCents = getStatementOutstandingCents(statement);
if (outstandingCents === 0) {
return "paid";
}
if (new Date(statement.dueAt).getTime() < referenceDate.getTime()) {
return "overdue";
}
return statement.payments.length > 0 ? "partial" : "pending";
}
export function toBillingStatementView(
statement: BillingStatement,
referenceDate = new Date(),
): BillingStatementView {
const totalCents = getStatementTotalCents(statement);
const paidCents = getStatementPaidCents(statement);
return {
...statement,
totalCents,
paidCents,
outstandingCents: Math.max(0, totalCents - paidCents),
status: getBillingStatus(statement, referenceDate),
};
}
export function calculateBillingSummary(
statements: readonly BillingStatement[],
referenceDate = new Date(),
): BillingSummary {
return statements.reduce<BillingSummary>(
(summary, statement) => {
const view = toBillingStatementView(statement, referenceDate);
return {
receivableCents: summary.receivableCents + view.totalCents,
collectedCents: summary.collectedCents + view.paidCents,
outstandingCents: summary.outstandingCents + view.outstandingCents,
overdueCount: summary.overdueCount + (view.status === "overdue" ? 1 : 0),
};
},
{ receivableCents: 0, collectedCents: 0, outstandingCents: 0, overdueCount: 0 },
);
}
export function addBillingPayment(statement: BillingStatement, payment: BillingPayment): BillingStatement {
return {
...statement,
payments: [payment, ...statement.payments],
};
}
export function addBillingCharge(statement: BillingStatement, charge: BillingCharge): BillingStatement {
return {
...statement,
charges: [...statement.charges, charge],
invoiceStatus: "not_requested",
invoiceNumber: undefined,
};
}
export function formatCny(cents: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
minimumFractionDigits: 2,
}).format(cents / 100);
}
export function formatBillingPeriod(period: string): string {
const [year, month] = period.split("-");
return year && month ? `${year}${month}` : period;
}