Compare commits
6 Commits
3aabdf0c4a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee502a97ed | |||
| 929beb7d84 | |||
| 4efb8ea3b6 | |||
| 2682a509dc | |||
| b8d08cd461 | |||
| ef4dd21d32 |
@@ -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."}
|
||||||
@@ -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.
|
||||||
@@ -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."}
|
||||||
@@ -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.
|
||||||
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal file
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal 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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"_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."}
|
||||||
|
{"file": ".trellis/workflow.md", "reason": "核验任务验收与 Git 操作边界"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"_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."}
|
||||||
|
{"file": ".trellis/workflow.md", "reason": "遵循任务执行、Git 安全边界与收尾流程"}
|
||||||
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal file
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 核查并同步 Git 远端
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
确认本地工作区及 `main` 分支的全部已提交内容已同步到项目配置的云端 Git 仓库,避免本地提交遗漏。
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
- 当前工作树干净,没有未提交或未跟踪文件。
|
||||||
|
- 本地 `main` 当前提交为 `2682a509dcbbe0ab7b1e3226a5363d580adf8763`。
|
||||||
|
- `main` 跟踪 `gitea/main`,本地领先 3 个提交。
|
||||||
|
- `github` 与 `origin` 指向同一 GitHub 仓库地址;`gitea` 指向独立 Gitea 仓库。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 拉取各唯一远端的最新引用,准确比较本地 `main` 与云端 `main` 的提交关系。
|
||||||
|
- 若远端没有本地未知提交,则以普通、非强制方式推送本地 `main`。
|
||||||
|
- 不修改代码、提交历史、分支结构或远端配置。
|
||||||
|
- 同一仓库地址只需推送一次,避免对 `github` / `origin` 重复操作。
|
||||||
|
- 推送后再次核对工作树与各目标远端引用。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `git status` 显示工作树干净。
|
||||||
|
- [ ] 本地 `main` 的已提交内容已推送到 Gitea 与 GitHub 两个唯一远端仓库。
|
||||||
|
- [ ] 推送后各目标远端 `main` 与本地 `main` 指向同一提交。
|
||||||
|
- [ ] 未使用强制推送,也未重写提交历史或更改远端配置。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 创建新提交、修改项目文件、调整 Git 托管配置或创建其他分支/标签。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "sync-git-remotes",
|
||||||
|
"name": "sync-git-remotes",
|
||||||
|
"title": "核查并同步 Git 远端",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-27",
|
||||||
|
"completedAt": "2026-07-27",
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 15
|
- **Total Sessions**: 17
|
||||||
- **Last Active**: 2026-07-09
|
- **Last Active**: 2026-07-27
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- @@@auto:active-documents -->
|
<!-- @@@auto:active-documents -->
|
||||||
| File | Lines | Status |
|
| File | Lines | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| `journal-1.md` | ~519 | Active |
|
| `journal-1.md` | ~585 | Active |
|
||||||
<!-- @@@/auto:active-documents -->
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
<!-- @@@auto:session-history -->
|
<!-- @@@auto:session-history -->
|
||||||
| # | Date | Title | Commits | Branch |
|
| # | Date | Title | Commits | Branch |
|
||||||
|---|------|-------|---------|--------|
|
|---|------|-------|---------|--------|
|
||||||
|
| 17 | 2026-07-27 | 核查并同步 Git 远端 | `4efb8ea` | `main` |
|
||||||
|
| 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` |
|
| 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` |
|
| 14 | 2026-07-06 | Harden AI analysis path | `6ed7508`, `ae561a7`, `0d5093a`, `f74b7f3` | `main` |
|
||||||
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
|
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
|
||||||
|
|||||||
@@ -517,3 +517,69 @@ Completed operations dashboard AI board work, bounded elder AI provider latency
|
|||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
- None - task complete
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
## Session 17: 核查并同步 Git 远端
|
||||||
|
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
**Task**: 核查并同步 Git 远端
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
核对本地工作区及 main 分支,将提交安全同步到 Gitea 与 GitHub,并验证两个远端 main 与本地 HEAD 一致。
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `4efb8ea` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|||||||
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import BillingPage from "../../billing/page";
|
||||||
|
|
||||||
|
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
|
||||||
|
return BillingPage();
|
||||||
|
}
|
||||||
20
app/(app)/app/billing/page.tsx
Normal file
20
app/(app)/app/billing/page.tsx
Normal 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()} />;
|
||||||
|
}
|
||||||
@@ -7,8 +7,14 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog } from "@/components/ui/dialog";
|
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 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";
|
import type { Elder } from "@/modules/elders/types";
|
||||||
|
|
||||||
type ElderAiAnalysisDialogProps = {
|
type ElderAiAnalysisDialogProps = {
|
||||||
@@ -30,6 +36,51 @@ const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
|
|||||||
failed: "未完成",
|
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 {
|
function formatDateTime(value: string): string {
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
@@ -82,10 +133,10 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
description={elder ? `${elder.name} 的授权数据范围内智能分析历史和最新结论。` : undefined}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open={open}
|
open={open}
|
||||||
title="AI 分析"
|
title="智能照护分析"
|
||||||
width="wide"
|
width="wide"
|
||||||
>
|
>
|
||||||
{elder ? (
|
{elder ? (
|
||||||
@@ -94,7 +145,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold">{elder.name}</p>
|
<p className="text-lg font-semibold">{elder.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -104,7 +155,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
</Button>
|
</Button>
|
||||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||||
<Sparkles className="size-4" aria-hidden="true" />
|
<Sparkles className="size-4" aria-hidden="true" />
|
||||||
{isGenerating ? "生成中" : "生成分析"}
|
{isGenerating ? "分析中" : "更新分析"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium">{finding.category}</span>
|
<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>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||||
</div>
|
</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="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium">{recommendation.title}</span>
|
<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>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||||
@@ -197,13 +250,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
||||||
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
||||||
{latest.status === "failed" ? <p>失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。</p> : null}
|
{latest.status === "failed" ? <p>系统已保留必要的状态信息,可稍后重新更新分析。</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 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>
|
<div>
|
||||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
{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">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-normal">知识库</h1>
|
<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>
|
</div>
|
||||||
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
||||||
<Plus className="size-4" aria-hidden="true" />
|
<Plus className="size-4" aria-hidden="true" />
|
||||||
@@ -203,7 +203,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
|||||||
</div>
|
</div>
|
||||||
<label className="relative block w-full md:max-w-sm">
|
<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" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{message ? (
|
{message ? (
|
||||||
@@ -271,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
description="保存时会写入数据库并重建分块;检索使用本地关键词匹配。"
|
description="保存后将纳入智能分析的知识参考范围。"
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
title={isEditing ? "编辑知识" : "新增知识"}
|
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" />
|
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
|
||||||
智能分析
|
智能分析
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>来自已保存老人 AI 分析历史,按数据权限自动脱敏。</CardDescription>
|
<CardDescription>汇总老人照护分析历史,并按数据权限展示可查看内容。</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
|
<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">
|
<label className="relative block">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="搜索老人档案"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
onChange={(event) => updateQuery(event.target.value)}
|
onChange={(event) => updateQuery(event.target.value)}
|
||||||
placeholder="搜索姓名、床位、联系人"
|
placeholder="搜索姓名、床位、联系人"
|
||||||
@@ -412,7 +413,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Brain className="size-4" aria-hidden="true" />
|
<Brain className="size-4" aria-hidden="true" />
|
||||||
AI 分析
|
智能分析
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Radio,
|
Radio,
|
||||||
|
ReceiptText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
TabletSmartphone,
|
TabletSmartphone,
|
||||||
@@ -32,6 +33,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
|
|||||||
alerts: Radio,
|
alerts: Radio,
|
||||||
audit: ListChecks,
|
audit: ListChecks,
|
||||||
beds: BedDouble,
|
beds: BedDouble,
|
||||||
|
billing: ReceiptText,
|
||||||
care: ClipboardCheck,
|
care: ClipboardCheck,
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
devices: TabletSmartphone,
|
devices: TabletSmartphone,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type NavIconKey =
|
|||||||
| "alerts"
|
| "alerts"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "beds"
|
| "beds"
|
||||||
|
| "billing"
|
||||||
| "care"
|
| "care"
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "devices"
|
| "devices"
|
||||||
@@ -53,6 +54,12 @@ export const navGroups: NavGroup[] = [
|
|||||||
icon: "beds",
|
icon: "beds",
|
||||||
anyPermissions: ["facility:read", "admission:read"],
|
anyPermissions: ["facility:read", "admission:read"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/billing",
|
||||||
|
label: "费用管理",
|
||||||
|
icon: "billing",
|
||||||
|
permission: "admission:manage",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/health",
|
path: "/health",
|
||||||
label: "健康照护",
|
label: "健康照护",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
"elders",
|
"elders",
|
||||||
"beds",
|
"beds",
|
||||||
|
"billing",
|
||||||
"health",
|
"health",
|
||||||
"care",
|
"care",
|
||||||
"emergency",
|
"emergency",
|
||||||
|
|||||||
Reference in New Issue
Block a user