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>
);
}