feat: add fee workspace and polish AI presentation
This commit is contained in:
@@ -7,8 +7,14 @@ 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 type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
|
||||
import type {
|
||||
ElderAiAnalysisHistoryItem,
|
||||
ElderAiDataScope,
|
||||
ElderAiRecommendationPriority,
|
||||
ElderAiSeverity,
|
||||
} from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types";
|
||||
import type { Elder } from "@/modules/elders/types";
|
||||
|
||||
type ElderAiAnalysisDialogProps = {
|
||||
@@ -30,6 +36,51 @@ const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
|
||||
failed: "未完成",
|
||||
};
|
||||
|
||||
const severityLabels: Record<ElderAiSeverity, string> = {
|
||||
info: "提示",
|
||||
warning: "关注",
|
||||
critical: "紧急",
|
||||
};
|
||||
|
||||
const recommendationPriorityLabels: Record<ElderAiRecommendationPriority, string> = {
|
||||
low: "低",
|
||||
normal: "常规",
|
||||
high: "高",
|
||||
urgent: "紧急",
|
||||
};
|
||||
|
||||
const dataScopeLabels: Record<ElderAiDataScope, string> = {
|
||||
elder: "基础档案",
|
||||
health: "健康数据",
|
||||
care: "护理服务",
|
||||
family: "家属服务",
|
||||
admission: "入住信息",
|
||||
facility: "床位设施",
|
||||
alert: "规则预警",
|
||||
incident: "安全事件",
|
||||
knowledge: "知识库",
|
||||
};
|
||||
|
||||
function severityVariant(severity: ElderAiSeverity): "danger" | "secondary" | "warning" {
|
||||
if (severity === "critical") {
|
||||
return "danger";
|
||||
}
|
||||
if (severity === "warning") {
|
||||
return "warning";
|
||||
}
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function recommendationVariant(priority: ElderAiRecommendationPriority): "danger" | "secondary" | "warning" {
|
||||
if (priority === "urgent") {
|
||||
return "danger";
|
||||
}
|
||||
if (priority === "high") {
|
||||
return "warning";
|
||||
}
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
@@ -82,10 +133,10 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
||||
description={elder ? `${elder.name} 的授权数据范围内智能分析历史和最新结论。` : undefined}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title="AI 分析"
|
||||
title="智能照护分析"
|
||||
width="wide"
|
||||
>
|
||||
{elder ? (
|
||||
@@ -94,7 +145,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{elder.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{elder.age} 岁 · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||
{elder.age} 岁 · {CARE_LEVEL_LABELS[elder.careLevel]} · {ELDER_STATUS_LABELS[elder.status]} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -104,7 +155,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
</Button>
|
||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{isGenerating ? "生成中" : "生成分析"}
|
||||
{isGenerating ? "分析中" : "更新分析"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +188,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{finding.category}</span>
|
||||
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
|
||||
<Badge variant={severityVariant(finding.severity)}>{severityLabels[finding.severity]}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||
</div>
|
||||
@@ -151,7 +202,9 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{recommendation.title}</span>
|
||||
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
|
||||
<Badge variant={recommendationVariant(recommendation.priority)}>
|
||||
{recommendationPriorityLabels[recommendation.priority]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||
@@ -197,13 +250,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
||||
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
||||
{latest.status === "failed" ? <p>失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。</p> : null}
|
||||
{latest.status === "failed" ? <p>系统已保留必要的状态信息,可稍后重新更新分析。</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
||||
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -215,7 +268,9 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
||||
<div>
|
||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
||||
<p className="text-xs text-muted-foreground">范围:{item.dataScopes.join(", ") || "-"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
范围:{item.dataScopes.map((scope) => dataScopeLabels[scope]).join("、") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">维护平台共享和机构私有知识,为智能分析提供统一的制度与照护参考。</p>
|
||||
</div>
|
||||
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
@@ -203,7 +203,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
||||
</div>
|
||||
<label className="relative block w-full md:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
||||
<Input aria-label="搜索知识条目" className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
||||
</label>
|
||||
</div>
|
||||
{message ? (
|
||||
@@ -271,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
description="保存时会写入数据库并重建分块;检索使用本地关键词匹配。"
|
||||
description="保存后将纳入智能分析的知识参考范围。"
|
||||
onClose={closeDialog}
|
||||
open={isDialogOpen}
|
||||
title={isEditing ? "编辑知识" : "新增知识"}
|
||||
|
||||
832
modules/billing/components/BillingWorkspaceClient.tsx
Normal file
832
modules/billing/components/BillingWorkspaceClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
modules/billing/lib/presentation-data.ts
Normal file
212
modules/billing/lib/presentation-data.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
75
modules/billing/types.test.ts
Normal file
75
modules/billing/types.test.ts
Normal 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
169
modules/billing/types.ts
Normal 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;
|
||||
}
|
||||
@@ -449,7 +449,7 @@ export function DashboardHome({
|
||||
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
|
||||
智能分析
|
||||
</CardTitle>
|
||||
<CardDescription>来自已保存老人 AI 分析历史,按数据权限自动脱敏。</CardDescription>
|
||||
<CardDescription>汇总老人照护分析历史,并按数据权限展示可查看内容。</CardDescription>
|
||||
</div>
|
||||
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
|
||||
老人档案
|
||||
|
||||
@@ -335,6 +335,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
||||
<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) => updateQuery(event.target.value)}
|
||||
placeholder="搜索姓名、床位、联系人"
|
||||
@@ -412,7 +413,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
||||
variant="outline"
|
||||
>
|
||||
<Brain className="size-4" aria-hidden="true" />
|
||||
AI 分析
|
||||
智能分析
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ListChecks,
|
||||
LockKeyhole,
|
||||
Radio,
|
||||
ReceiptText,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
TabletSmartphone,
|
||||
@@ -32,6 +33,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
|
||||
alerts: Radio,
|
||||
audit: ListChecks,
|
||||
beds: BedDouble,
|
||||
billing: ReceiptText,
|
||||
care: ClipboardCheck,
|
||||
dashboard: LayoutDashboard,
|
||||
devices: TabletSmartphone,
|
||||
|
||||
@@ -4,6 +4,7 @@ export type NavIconKey =
|
||||
| "alerts"
|
||||
| "audit"
|
||||
| "beds"
|
||||
| "billing"
|
||||
| "care"
|
||||
| "dashboard"
|
||||
| "devices"
|
||||
@@ -53,6 +54,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: "beds",
|
||||
anyPermissions: ["facility:read", "admission:read"],
|
||||
},
|
||||
{
|
||||
path: "/billing",
|
||||
label: "费用管理",
|
||||
icon: "billing",
|
||||
permission: "admission:manage",
|
||||
},
|
||||
{
|
||||
path: "/health",
|
||||
label: "健康照护",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
|
||||
"dashboard",
|
||||
"elders",
|
||||
"beds",
|
||||
"billing",
|
||||
"health",
|
||||
"care",
|
||||
"emergency",
|
||||
|
||||
Reference in New Issue
Block a user