833 lines
31 KiB
TypeScript
833 lines
31 KiB
TypeScript
"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>
|
|
);
|
|
}
|