diff --git a/.trellis/tasks/07-09-ai-fee-frontend/check.jsonl b/.trellis/tasks/07-09-ai-fee-frontend/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-09-ai-fee-frontend/design.md b/.trellis/tasks/07-09-ai-fee-frontend/design.md new file mode 100644 index 0000000..b52c91f --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/design.md @@ -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. diff --git a/.trellis/tasks/07-09-ai-fee-frontend/implement.jsonl b/.trellis/tasks/07-09-ai-fee-frontend/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-09-ai-fee-frontend/implement.md b/.trellis/tasks/07-09-ai-fee-frontend/implement.md new file mode 100644 index 0000000..4687880 --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/implement.md @@ -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. diff --git a/.trellis/tasks/07-09-ai-fee-frontend/prd.md b/.trellis/tasks/07-09-ai-fee-frontend/prd.md new file mode 100644 index 0000000..d349bd9 --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/prd.md @@ -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. diff --git a/.trellis/tasks/07-09-ai-fee-frontend/task.json b/.trellis/tasks/07-09-ai-fee-frontend/task.json new file mode 100644 index 0000000..97f44f9 --- /dev/null +++ b/.trellis/tasks/07-09-ai-fee-frontend/task.json @@ -0,0 +1,26 @@ +{ + "id": "ai-fee-frontend", + "name": "ai-fee-frontend", + "title": "梳理 AI 功能并补齐费用前端", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-09", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/(app)/app/[organizationSlug]/billing/page.tsx b/app/(app)/app/[organizationSlug]/billing/page.tsx new file mode 100644 index 0000000..159f84e --- /dev/null +++ b/app/(app)/app/[organizationSlug]/billing/page.tsx @@ -0,0 +1,5 @@ +import BillingPage from "../../billing/page"; + +export default async function ScopedBillingPage(): Promise { + return BillingPage(); +} diff --git a/app/(app)/app/billing/page.tsx b/app/(app)/app/billing/page.tsx new file mode 100644 index 0000000..69e5dd3 --- /dev/null +++ b/app/(app)/app/billing/page.tsx @@ -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 { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + if (!context.organization || !hasPermission(context.permissions, "admission:manage")) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + + return ; +} diff --git a/modules/ai/components/ElderAiAnalysisDialog.tsx b/modules/ai/components/ElderAiAnalysisDialog.tsx index 2c86b62..4d9666d 100644 --- a/modules/ai/components/ElderAiAnalysisDialog.tsx +++ b/modules/ai/components/ElderAiAnalysisDialog.tsx @@ -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 = { failed: "未完成", }; +const severityLabels: Record = { + info: "提示", + warning: "关注", + critical: "紧急", +}; + +const recommendationPriorityLabels: Record = { + low: "低", + normal: "常规", + high: "高", + urgent: "紧急", +}; + +const dataScopeLabels: Record = { + 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 ( {elder ? ( @@ -94,7 +145,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD

{elder.name}

- {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}` : "未绑定床位"}

@@ -104,7 +155,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
@@ -137,7 +188,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
{finding.category} - {finding.severity} + {severityLabels[finding.severity]}

{finding.evidence}

@@ -151,7 +202,9 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
{recommendation.title} - {recommendation.priority} + + {recommendationPriorityLabels[recommendation.priority]} +

{recommendation.rationale}

{recommendation.suggestedNextStep}

@@ -197,13 +250,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD

{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}

- {latest.status === "failed" ?

失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。

: null} + {latest.status === "failed" ?

系统已保留必要的状态信息,可稍后重新更新分析。

: null}
) : ( - {isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"} + {isLoading ? "正在加载分析历史" : "暂无智能分析历史"} )} @@ -215,7 +268,9 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD

{formatDateTime(item.createdAt)}

-

范围:{item.dataScopes.join(", ") || "-"}

+

+ 范围:{item.dataScopes.map((scope) => dataScopeLabels[scope]).join("、") || "-"} +

{item.restricted ? 受限 : null} diff --git a/modules/ai/components/KnowledgeManagementClient.tsx b/modules/ai/components/KnowledgeManagementClient.tsx index 62f7270..5b45067 100644 --- a/modules/ai/components/KnowledgeManagementClient.tsx +++ b/modules/ai/components/KnowledgeManagementClient.tsx @@ -186,7 +186,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl

知识库

-

维护平台共享和机构私有知识,保存后会重建本地关键词检索分块。

+

维护平台共享和机构私有知识,为智能分析提供统一的制度与照护参考。

{message ? ( @@ -271,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl = { + 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(); + 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): void { + setDialog((current) => + current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current, + ); + } + + function updateChargeForm(next: Partial): void { + setDialog((current) => + current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current, + ); + } + + function submitPayment(event: FormEvent): 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): 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 ( +
+
+ + + + +
+ +
+
+

费用管理

+

统一查看老人月度账单、费用项目、收款记录和票据状态。

+
+
+ + onUpdate({ amountYuan: event.target.value })} + required + step="0.01" + type="number" + value={form.amountYuan} + /> + onUpdate({ paidAt: event.target.value })} + required + type="datetime-local" + value={form.paidAt} + /> + onUpdate({ reference: event.target.value })} + placeholder="留空后自动生成" + value={form.reference} + /> +
+