Compare commits

...

3 Commits

21 changed files with 1625 additions and 17 deletions

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,80 @@
# AI Presentation And Fee Workspace Design
## Scope
This task keeps the current AI product surface and adds one new frontend-only fee workspace. It intentionally does not add a separate AI center, a billing database schema, or external finance integrations.
## AI Boundary
The visible AI workflow remains split across the existing layers:
- `modules/ai/server/analysis.ts` keeps permission checks, elder context lookup, prepared structured outputs, persisted history metadata, and audit logging.
- `modules/ai/components/ElderAiAnalysisDialog.tsx` keeps loading history and requesting a refreshed analysis through the existing API.
- `modules/dashboard/components/DashboardHome.tsx` continues to present the organization-scoped prepared analysis board.
- `modules/ai/components/KnowledgeManagementClient.tsx` remains persisted knowledge CRUD, but user-facing copy describes business value rather than retrieval chunks or implementation details.
No external model provider is called by the visible analysis path. The implementation pass will audit visible wording and remove provider, local retrieval, seed, synthetic, mock, and demo language without weakening permission or restricted-result behavior.
## Fee Workspace Boundary
Create a new `modules/billing/` frontend domain:
- `types.ts` owns fee account, charge item, payment, status, and summary types plus display constants and pure calculation helpers.
- `lib/presentation-data.ts` owns the initial organization fee ledger used by the workspace.
- `components/BillingWorkspaceClient.tsx` owns all browser-session interactions.
Create routes:
- `app/(app)/app/billing/page.tsx`
- `app/(app)/app/[organizationSlug]/billing/page.tsx`
The server route performs the normal authentication and organization checks, gates the page with `admission:manage`, and passes the initial ledger into the client component. No API route or database table is added.
## Navigation
Add `费用管理` to the operations group after `床位房间`. Reuse `admission:manage` so only current institution administrators and operations managers see and operate the workspace. Add a Lucide wallet/receipt icon through the existing sidebar icon map.
## Fee Data Model
Each resident statement contains:
- statement id, resident identity, room/bed label, billing period, due date
- charge items with category, description, quantity, unit price, and amount
- payment records with amount, method, time, reference, operator, and note
- invoice state and receipt count
Totals and status are derived from charge and payment records:
- `paid`: outstanding amount is zero
- `partial`: at least one payment exists and an outstanding amount remains
- `pending`: no payment and not past due
- `overdue`: outstanding amount remains after the due date
All currency is represented as integer cents internally and formatted as CNY for display.
## Interactions
The fee workspace provides:
- overview metrics for current receivables, collected amount, outstanding amount, and overdue households
- resident/statement search and status filtering
- statement detail dialog with itemized charges and payment history
- charge registration dialog that appends a new item and recalculates totals
- payment registration dialog with amount and method validation, then local statement/status updates
- receipt and invoice actions that update the current browser-session record and return credible product feedback
Controls must not be decorative. Every visible command either changes local state, opens usable detail, or returns explicit success/error feedback.
## Responsive Layout
- Use the existing `max-w-7xl`, operational header, metric cards, table, badge, select, input, button, and dialog patterns.
- Keep tables horizontally scrollable with explicit minimum widths.
- Stack toolbar controls and dialog form fields at narrow widths.
- Keep touch targets at least the existing button height and avoid text overlap.
## Compatibility And Rollback
- Existing AI API and database contracts remain unchanged.
- No migration or production data backfill is required.
- Rollback consists of removing the billing route/module/nav item and reverting the small AI copy edits.
- The task-specific requirement for a frontend-only fee presentation workspace is an intentional exception to the generic persisted-operational-module guideline.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,44 @@
# AI Presentation And Fee Workspace Implementation Plan
## 1. AI Surface Audit
- [x] Confirm the visible elder analysis path has no external provider call.
- [x] Replace technical knowledge retrieval and failure-history wording with product-facing copy.
- [x] Keep analysis history, refresh/generate behavior, citations, and permission redaction intact.
- [x] Add or adjust focused assertions for user-visible AI copy where practical.
## 2. Fee Domain
- [x] Add billing types, labels, currency helpers, status derivation, and summary calculations.
- [x] Add realistic initial resident statements with itemized fees and payments.
- [x] Add unit tests for amount totals, payment application, and status derivation.
## 3. Fee Workspace UI
- [x] Build overview metrics, search, filters, and responsive statements table.
- [x] Build statement detail with charge items and payment history.
- [x] Build charge registration and payment registration flows with validation.
- [x] Build receipt and invoice actions with state updates and visible feedback.
## 4. Routing And Navigation
- [x] Add scoped and unscoped billing routes with authentication and `admission:manage` gating.
- [x] Add `费用管理` and a Lucide icon to the operations navigation.
- [x] Verify workspace breadcrumbs and organization-scoped links resolve correctly.
## 5. Validation
- [x] Run focused billing and AI tests.
- [x] Run `pnpm lint`.
- [x] Run `pnpm type-check`.
- [x] Run `pnpm test`.
- [x] Run `pnpm build`.
- [x] Start the development server and smoke-test AI and billing flows in the browser at desktop and mobile widths.
- [x] Check browser console errors, text clipping, dialog geometry, and interactive state updates.
## Risk And Rollback Points
- Keep the billing domain isolated so removal does not affect persisted modules.
- Do not change `Permission`, role seed, schema, or migrations for the frontend-only workspace.
- Reuse `admission:manage`; changing authorization is out of scope.
- Do not alter AI API response shapes or stored analysis records.

View File

@@ -0,0 +1,43 @@
# AI Presentation And Fee Workspace PRD
## Goal
Make the product presentation-ready by clarifying and stabilizing the visible AI workflows, then add a complete-looking fee management workspace that behaves like a normal product feature without exposing implementation shortcuts in the UI.
## Background
- The current elder AI analysis no longer calls an external model provider. `modules/ai/server/analysis.ts` builds prepared structured analysis outputs while preserving permission checks, elder context lookup, history records, and audit behavior.
- The dashboard AI board also renders prepared analysis content and fills missing records with generated presentation data.
- The elder AI dialog still uses the AI API for history loading and analysis generation, and knowledge management still uses persisted CRUD APIs.
- The product has no fee, billing, payment, invoice, or receipt workspace, permission, API, or database model.
- Earlier project scope treated fee records as a useful complete-product capability while payment settlement, insurance integration, and external finance integrations were deferred.
## Requirements
1. Audit all visible AI entry points and remove wording or interaction behavior that exposes provider, seed, synthetic, mock, or demo implementation details.
2. Keep AI interactions stable and immediately usable in a presentation environment without depending on an external model service.
3. Preserve the existing authorization and resident data-scope presentation behavior for AI analysis.
4. Add a discoverable fee management workspace under the main operations navigation.
5. Implement the fee workspace as frontend-only interactive state with realistic resident account data and no new database schema or payment integration.
6. The fee workspace must cover an operational loop rather than a static screen: overview metrics, account search/filtering, charge detail, payment registration, and receipt/invoice-oriented actions.
7. All user-visible labels and messages must read as production product copy. Do not display `mock`, `demo`, `sample`, `seed`, `synthetic`, `placeholder`, or equivalent Chinese wording.
8. Keep visual structure consistent with the existing quiet operational dashboard, table, badge, dialog, and responsive layout patterns.
## Acceptance Criteria
- [x] Every visible AI route and action has been classified as prepared/local, persisted backend, or external-provider dependent, and the final visible workflow has no external-provider dependency.
- [x] Elder AI analysis opens with usable history, can produce a new structured result, and never exposes implementation-specific wording.
- [x] Knowledge management remains coherent with the chosen AI presentation scope and contains no technical copy about local retrieval chunks or provider configuration.
- [x] A `费用管理` navigation entry opens in both scoped and unscoped workspace routes.
- [x] The fee page shows realistic totals for receivables, collected amount, outstanding amount, and overdue accounts.
- [x] Users can search and filter resident accounts, open a statement/detail view, register a payment, and see the affected account totals/status update in the current browser session.
- [x] Users can invoke receipt or invoice-oriented actions with credible success feedback and without dead controls.
- [x] The fee workspace is usable at desktop and mobile widths without overlapping controls or clipped text.
- [x] Lint, type-check, focused tests, production build, and browser smoke verification pass.
## Out Of Scope
- Persisting fee data to PostgreSQL.
- Real payment gateways, refunds, reconciliation files, tax invoices, insurance, or medical reimbursement integrations.
- Changing the core authorization model solely for the frontend fee workspace.
- Adding a new external AI provider path or restoring model calls.

View File

@@ -0,0 +1,26 @@
{
"id": "ai-fee-frontend",
"name": "ai-fee-frontend",
"title": "梳理 AI 功能并补齐费用前端",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-09",
"completedAt": "2026-07-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 15
- **Total Sessions**: 16
- **Last Active**: 2026-07-09
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~519 | Active |
| `journal-1.md` | ~552 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 16 | 2026-07-09 | Polish AI presentation, add fee workspace, and deploy | `ef4dd21` | `main` |
| 15 | 2026-07-09 | Complete ops AI board and deploy latency controls | `a8776f9`, `153c501` | `main` |
| 14 | 2026-07-06 | Harden AI analysis path | `6ed7508`, `ae561a7`, `0d5093a`, `f74b7f3` | `main` |
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |

View File

@@ -517,3 +517,36 @@ Completed operations dashboard AI board work, bounded elder AI provider latency
### Next Steps
- None - task complete
## Session 16: Polish AI presentation, add fee workspace, and deploy
**Date**: 2026-07-09
**Task**: Polish AI presentation, add fee workspace, and deploy
**Branch**: `main`
### Summary
Added a frontend fee management workspace with billing totals, statement details, charge and payment registration, receipts, and invoices; polished AI and knowledge UI copy; validated desktop/mobile flows; pushed and deployed release 20260710080833-ef4dd21 to wlcb1 while restoring systemd as the sole process manager.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `ef4dd21` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View File

@@ -0,0 +1,5 @@
import BillingPage from "../../billing/page";
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
return BillingPage();
}

View File

@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { BillingWorkspaceClient } from "@/modules/billing/components/BillingWorkspaceClient";
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function BillingPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!context.organization || !hasPermission(context.permissions, "admission:manage")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
return <BillingWorkspaceClient initialStatements={createInitialBillingStatements()} />;
}

View File

@@ -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}

View File

@@ -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 ? "编辑知识" : "新增知识"}

View File

@@ -0,0 +1,832 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import {
Banknote,
CircleDollarSign,
Eye,
FilePlus2,
FileText,
HandCoins,
Plus,
ReceiptText,
Search,
TriangleAlert,
WalletCards,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import {
BILLING_STATUS_LABELS,
BILLING_STATUS_VALUES,
INVOICE_STATUS_LABELS,
PAYMENT_METHOD_LABELS,
PAYMENT_METHOD_VALUES,
addBillingCharge,
addBillingPayment,
calculateBillingSummary,
formatBillingPeriod,
formatCny,
toBillingStatementView,
} from "@/modules/billing/types";
import type {
BillingCharge,
BillingStatement,
BillingStatementView,
BillingStatus,
InvoiceStatus,
PaymentMethod,
} from "@/modules/billing/types";
type BillingWorkspaceClientProps = {
initialStatements: BillingStatement[];
};
type PaymentFormState = {
amountYuan: string;
method: PaymentMethod;
paidAt: string;
reference: string;
note: string;
};
type ChargeFormState = {
category: string;
description: string;
quantity: string;
serviceDate: string;
unitPriceYuan: string;
};
type DialogState =
| { kind: "detail"; statementId: string }
| { kind: "payment"; statementId: string; form: PaymentFormState }
| { kind: "charge"; statementId: string; form: ChargeFormState };
const STATUS_SORT_ORDER: Record<BillingStatus, number> = {
overdue: 0,
partial: 1,
pending: 2,
paid: 3,
};
const chargeCategoryOptions = [
{ value: "床位服务", label: "床位服务" },
{ value: "护理服务", label: "护理服务" },
{ value: "膳食服务", label: "膳食服务" },
{ value: "健康管理", label: "健康管理" },
{ value: "康复服务", label: "康复服务" },
{ value: "生活用品", label: "生活用品" },
{ value: "其他费用", label: "其他费用" },
];
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("zh-CN");
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function toDateInputValue(date: Date): string {
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
return localDate.toISOString().slice(0, 10);
}
function toDateTimeInputValue(date: Date): string {
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
function parseYuanToCents(value: string): number | undefined {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
return undefined;
}
return Math.round(amount * 100);
}
function getStatusVariant(status: BillingStatus): "danger" | "secondary" | "success" | "warning" {
if (status === "paid") {
return "success";
}
if (status === "overdue") {
return "danger";
}
if (status === "partial") {
return "warning";
}
return "secondary";
}
function getInvoiceVariant(status: InvoiceStatus): "secondary" | "success" | "warning" {
if (status === "issued") {
return "success";
}
if (status === "pending") {
return "warning";
}
return "secondary";
}
export function BillingWorkspaceClient({ initialStatements }: BillingWorkspaceClientProps): React.ReactElement {
const [statements, setStatements] = useState(initialStatements);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | BillingStatus>("all");
const [dialog, setDialog] = useState<DialogState>();
const [message, setMessage] = useState("");
const [referenceDate] = useState(() => new Date());
const statementViews = useMemo(
() =>
statements
.map((statement) => toBillingStatementView(statement, referenceDate))
.sort((left, right) => STATUS_SORT_ORDER[left.status] - STATUS_SORT_ORDER[right.status]),
[referenceDate, statements],
);
const summary = useMemo(
() => calculateBillingSummary(statements, referenceDate),
[referenceDate, statements],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredStatements = useMemo(
() =>
statementViews.filter((statement) => {
const matchesStatus = statusFilter === "all" || statement.status === statusFilter;
const matchesQuery =
!normalizedQuery ||
[
statement.id,
statement.elderName,
statement.roomLabel,
statement.contactName,
statement.contactPhone,
]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
return matchesStatus && matchesQuery;
}),
[normalizedQuery, statementViews, statusFilter],
);
const selectedStatement = dialog
? statementViews.find((statement) => statement.id === dialog.statementId)
: undefined;
function openPayment(statement: BillingStatementView): void {
setMessage("");
setDialog({
kind: "payment",
statementId: statement.id,
form: {
amountYuan: (statement.outstandingCents / 100).toFixed(2),
method: "wechat",
paidAt: toDateTimeInputValue(new Date()),
reference: "",
note: "",
},
});
}
function openCharge(statement: BillingStatementView): void {
setMessage("");
setDialog({
kind: "charge",
statementId: statement.id,
form: {
category: "护理服务",
description: "",
quantity: "1",
serviceDate: toDateInputValue(new Date()),
unitPriceYuan: "",
},
});
}
function updatePaymentForm(next: Partial<PaymentFormState>): void {
setDialog((current) =>
current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function updateChargeForm(next: Partial<ChargeFormState>): void {
setDialog((current) =>
current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function submitPayment(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (dialog?.kind !== "payment" || !selectedStatement) {
return;
}
const amountCents = parseYuanToCents(dialog.form.amountYuan);
if (!amountCents) {
setMessage("请输入有效的收款金额");
return;
}
if (amountCents > selectedStatement.outstandingCents) {
setMessage(`收款金额不能超过待收金额 ${formatCny(selectedStatement.outstandingCents)}`);
return;
}
const paidAt = new Date(dialog.form.paidAt);
if (Number.isNaN(paidAt.getTime())) {
setMessage("请选择有效的收款时间");
return;
}
const statementId = selectedStatement.id;
const payment = {
id: `${statementId}-payment-${Date.now()}`,
amountCents,
method: dialog.form.method,
paidAt: paidAt.toISOString(),
reference: dialog.form.reference.trim() || `PAY${Date.now()}`,
operator: "当前操作员",
note: dialog.form.note.trim(),
};
setStatements((current) =>
current.map((statement) =>
statement.id === statementId ? addBillingPayment(statement, payment) : statement,
),
);
setDialog({ kind: "detail", statementId });
setMessage(`${selectedStatement.elderName} 收款 ${formatCny(amountCents)} 已登记`);
}
function submitCharge(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (dialog?.kind !== "charge" || !selectedStatement) {
return;
}
const quantity = Number(dialog.form.quantity);
const unitPriceCents = parseYuanToCents(dialog.form.unitPriceYuan);
if (!dialog.form.description.trim()) {
setMessage("请输入费用项目说明");
return;
}
if (!Number.isFinite(quantity) || quantity <= 0) {
setMessage("请输入有效的数量");
return;
}
if (!unitPriceCents) {
setMessage("请输入有效的单价");
return;
}
const serviceDate = new Date(`${dialog.form.serviceDate}T12:00:00`);
if (Number.isNaN(serviceDate.getTime())) {
setMessage("请选择有效的服务日期");
return;
}
const statementId = selectedStatement.id;
const charge: BillingCharge = {
id: `${statementId}-charge-${Date.now()}`,
category: dialog.form.category,
description: dialog.form.description.trim(),
quantity,
serviceDate: serviceDate.toISOString(),
unitPriceCents,
};
setStatements((current) =>
current.map((statement) =>
statement.id === statementId ? addBillingCharge(statement, charge) : statement,
),
);
setDialog({ kind: "detail", statementId });
setMessage(`${selectedStatement.elderName} 新增费用 ${formatCny(Math.round(quantity * unitPriceCents))}`);
}
function generateReceipt(statement: BillingStatementView): void {
if (statement.payments.length === 0 || statement.receiptCount >= statement.payments.length) {
setMessage("当前收款记录均已生成收据");
return;
}
setStatements((current) =>
current.map((item) =>
item.id === statement.id ? { ...item, receiptCount: item.payments.length } : item,
),
);
setMessage(`${statement.elderName} 的最新收款收据已生成`);
}
function issueInvoice(statement: BillingStatementView): void {
if (statement.outstandingCents > 0) {
setMessage("账单结清后才能开具发票");
return;
}
if (statement.invoiceStatus === "issued") {
setMessage(`发票 ${statement.invoiceNumber ?? ""} 已开具`);
return;
}
const compactDate = new Date().toISOString().slice(0, 10).replaceAll("-", "");
const invoiceNumber = `FP${compactDate}${statement.id.slice(-3).toUpperCase()}`;
setStatements((current) =>
current.map((item) =>
item.id === statement.id ? { ...item, invoiceStatus: "issued", invoiceNumber } : item,
),
);
setMessage(`${statement.elderName} 的电子发票已开具:${invoiceNumber}`);
}
const dialogTitle =
dialog?.kind === "payment" ? "登记收款" : dialog?.kind === "charge" ? "登记费用" : "账单详情";
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CircleDollarSign} label="本月应收" value={formatCny(summary.receivableCents)} />
<MetricCard icon={HandCoins} label="本月已收" value={formatCny(summary.collectedCents)} />
<MetricCard icon={WalletCards} label="待收金额" value={formatCny(summary.outstandingCents)} />
<MetricCard icon={TriangleAlert} label="逾期账户" value={`${summary.overdueCount}`} tone="danger" />
</section>
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[34rem]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="搜索费用账单"
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、房间、联系人或账单号"
value={query}
/>
</label>
<Select
aria-label="账单状态"
onValueChange={(value) => setStatusFilter(value as "all" | BillingStatus)}
options={[
{ value: "all", label: "全部账单状态" },
...BILLING_STATUS_VALUES.map((status) => ({ value: status, label: BILLING_STATUS_LABELS[status] })),
]}
value={statusFilter}
/>
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<Card>
<CardHeader className="gap-1">
<CardTitle></CardTitle>
<CardDescription> {filteredStatements.length} </CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[1040px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"> / </Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredStatements.map((statement) => (
<Table.Row key={statement.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{statement.elderName}</p>
<p className="text-xs text-muted-foreground">
{formatBillingPeriod(statement.period)} / {statement.id.toUpperCase()}
</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{statement.roomLabel}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.totalCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums text-muted-foreground">{formatCny(statement.paidCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.outstandingCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(statement.dueAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getStatusVariant(statement.status)}>{BILLING_STATUS_LABELS[statement.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getInvoiceVariant(statement.invoiceStatus)}>
{INVOICE_STATUS_LABELS[statement.invoiceStatus]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
aria-label={`查看 ${statement.elderName} 账单`}
onClick={() => setDialog({ kind: "detail", statementId: statement.id })}
size="icon"
title="查看账单"
type="button"
variant="outline"
>
<Eye className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 费用`}
onClick={() => openCharge(statement)}
size="icon"
title="登记费用"
type="button"
variant="outline"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 收款`}
disabled={statement.outstandingCents === 0}
onClick={() => openPayment(statement)}
size="icon"
title="登记收款"
type="button"
>
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredStatements.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-10 text-center text-muted-foreground" colSpan={9}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog
description={selectedStatement ? `${selectedStatement.elderName} / ${formatBillingPeriod(selectedStatement.period)}` : undefined}
onClose={() => setDialog(undefined)}
open={dialog !== undefined && selectedStatement !== undefined}
title={dialogTitle}
width={dialog?.kind === "detail" ? "wide" : "lg"}
>
{dialog?.kind === "detail" && selectedStatement ? (
<StatementDetail
onCharge={() => openCharge(selectedStatement)}
onInvoice={() => issueInvoice(selectedStatement)}
onPayment={() => openPayment(selectedStatement)}
onReceipt={() => generateReceipt(selectedStatement)}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "payment" && selectedStatement ? (
<PaymentForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitPayment}
onUpdate={updatePaymentForm}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "charge" && selectedStatement ? (
<ChargeForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitCharge}
onUpdate={updateChargeForm}
/>
) : null}
</Dialog>
</div>
);
}
function StatementDetail({
onCharge,
onInvoice,
onPayment,
onReceipt,
statement,
}: {
onCharge: () => void;
onInvoice: () => void;
onPayment: () => void;
onReceipt: () => void;
statement: BillingStatementView;
}): React.ReactElement {
const canGenerateReceipt = statement.payments.length > statement.receiptCount;
const canIssueInvoice = statement.outstandingCents === 0 && statement.invoiceStatus !== "issued";
return (
<div className="grid gap-5">
<section className="grid gap-4 border-b pb-5 sm:grid-cols-2 lg:grid-cols-4">
<SummaryItem label="应收金额" value={formatCny(statement.totalCents)} />
<SummaryItem label="已收金额" value={formatCny(statement.paidCents)} />
<SummaryItem label="待收金额" value={formatCny(statement.outstandingCents)} />
<div>
<p className="text-xs text-muted-foreground"></p>
<Badge className="mt-2" variant={getStatusVariant(statement.status)}>
{BILLING_STATUS_LABELS[statement.status]}
</Badge>
</div>
</section>
<section className="grid gap-3 rounded-md border bg-secondary/25 p-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoItem label="房间床位" value={statement.roomLabel} />
<InfoItem label="缴费联系人" value={statement.contactName} />
<InfoItem label="联系电话" value={statement.contactPhone} />
<InfoItem label="到期日期" value={formatDate(statement.dueAt)} />
</section>
<section className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold"></h3>
<Button onClick={onCharge} size="sm" type="button" variant="outline">
<FilePlus2 className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.charges.map((charge) => (
<Table.Row key={charge.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{charge.description}</p>
<p className="text-xs text-muted-foreground">{charge.category}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(charge.serviceDate)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{charge.quantity}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{formatCny(charge.unitPriceCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">
{formatCny(Math.round(charge.quantity * charge.unitPriceCents))}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</section>
<section className="grid gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="mt-1 text-xs text-muted-foreground">
{statement.receiptCount}/{statement.payments.length} · {INVOICE_STATUS_LABELS[statement.invoiceStatus]}
{statement.invoiceNumber ? ` / ${statement.invoiceNumber}` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={statement.outstandingCents === 0} onClick={onPayment} size="sm" type="button">
<Banknote className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canGenerateReceipt} onClick={onReceipt} size="sm" type="button" variant="outline">
<ReceiptText className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canIssueInvoice} onClick={onInvoice} size="sm" type="button" variant="outline">
<FileText className="size-4" aria-hidden="true" />
{statement.invoiceStatus === "issued" ? "发票已开具" : "开具发票"}
</Button>
</div>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[760px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.payments.map((payment, index) => (
<Table.Row key={payment.id}>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(payment.paidAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">{PAYMENT_METHOD_LABELS[payment.method]}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.reference}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.operator}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={index < statement.receiptCount ? "success" : "secondary"}>
{index < statement.receiptCount ? "已生成" : "待生成"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">{formatCny(payment.amountCents)}</Table.Cell>
</Table.Row>
))}
{statement.payments.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</section>
</div>
);
}
function PaymentForm({
form,
onCancel,
onSubmit,
onUpdate,
statement,
}: {
form: PaymentFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<PaymentFormState>) => void;
statement: BillingStatementView;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="rounded-md border bg-secondary/25 p-4">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatCny(statement.outstandingCents)}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="收款金额(元)"
max={(statement.outstandingCents / 100).toFixed(2)}
min="0.01"
onChange={(event) => onUpdate({ amountYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.amountYuan}
/>
<Select
label="收款方式"
onValueChange={(value) => onUpdate({ method: value as PaymentMethod })}
options={PAYMENT_METHOD_VALUES.map((method) => ({ value: method, label: PAYMENT_METHOD_LABELS[method] }))}
value={form.method}
/>
<Input
label="收款时间"
onChange={(event) => onUpdate({ paidAt: event.target.value })}
required
type="datetime-local"
value={form.paidAt}
/>
<Input
label="支付流水号"
onChange={(event) => onUpdate({ reference: event.target.value })}
placeholder="留空后自动生成"
value={form.reference}
/>
</div>
<Textarea
label="备注"
onChange={(event) => onUpdate({ note: event.target.value })}
placeholder="填写付款人或对账说明"
value={form.note}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function ChargeForm({
form,
onCancel,
onSubmit,
onUpdate,
}: {
form: ChargeFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<ChargeFormState>) => void;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="grid gap-3 sm:grid-cols-2">
<Select
label="费用分类"
onValueChange={(value) => onUpdate({ category: value })}
options={chargeCategoryOptions}
value={form.category}
/>
<Input
label="服务日期"
onChange={(event) => onUpdate({ serviceDate: event.target.value })}
required
type="date"
value={form.serviceDate}
/>
</div>
<Input
label="费用项目说明"
onChange={(event) => onUpdate({ description: event.target.value })}
placeholder="例如:临时陪诊服务"
required
value={form.description}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="数量"
min="0.01"
onChange={(event) => onUpdate({ quantity: event.target.value })}
required
step="0.01"
type="number"
value={form.quantity}
/>
<Input
label="单价(元)"
min="0.01"
onChange={(event) => onUpdate({ unitPriceYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.unitPriceYuan}
/>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function MetricCard({
icon: Icon,
label,
tone = "default",
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
tone?: "danger" | "default";
value: string;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<CardTitle className="mt-2 truncate text-2xl tabular-nums">{value}</CardTitle>
</div>
<Icon className={tone === "danger" ? "size-5 shrink-0 text-destructive" : "size-5 shrink-0 text-primary"} aria-hidden={true} />
</CardHeader>
</Card>
);
}
function SummaryItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-sm font-medium">{value}</p>
</div>
);
}

View File

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

View File

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

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

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

View File

@@ -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}>

View File

@@ -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

View File

@@ -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,

View File

@@ -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: "健康照护",

View File

@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
"dashboard",
"elders",
"beds",
"billing",
"health",
"care",
"emergency",