feat: complete admission workspace

This commit is contained in:
2026-07-02 17:30:24 -07:00
parent c394d85236
commit 9480030391
13 changed files with 1335 additions and 350 deletions

View File

@@ -0,0 +1,28 @@
"use client";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export type BedOccupancyDatum = {
label: string;
count: number;
};
type BedOccupancyChartProps = {
data: BedOccupancyDatum[];
};
export function BedOccupancyChart({ data }: BedOccupancyChartProps): React.ReactElement {
return (
<div className="h-64 w-full">
<ResponsiveContainer height="100%" width="100%">
<BarChart data={data} margin={{ bottom: 8, left: -18, right: 8, top: 8 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tickLine={false} />
<YAxis allowDecimals={false} tickLine={false} />
<Tooltip cursor={{ fill: "rgba(15, 23, 42, 0.06)" }} />
<Bar dataKey="count" fill="var(--primary)" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -1,167 +1,196 @@
import {
Activity,
AlertTriangle,
BedDouble,
CheckCircle2,
Clock3,
HeartPulse,
ShieldAlert,
Users,
} from "lucide-react";
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, Users } from "lucide-react";
import type { LucideIcon } 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 { Table } from "@/components/ui/table";
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
type Metric = {
export type DashboardMetric = {
label: string;
value: string;
trend: string;
icon: LucideIcon;
icon: "users" | "beds" | "admissions" | "incidents";
};
const metrics: Metric[] = [
{ label: "在院", value: "126", trend: "+6 本周", icon: Users },
{ label: "床位", value: "82%", trend: "28 空闲", icon: BedDouble },
{ label: "任务", value: "184", trend: "142 完成", icon: CheckCircle2 },
{ label: "异常", value: "9", trend: "3 待复核", icon: HeartPulse },
];
export type DashboardAdmission = {
id: string;
elderName: string;
bedLabel: string;
status: AdmissionStatus;
admittedAt: string;
dischargedAt?: string;
};
const taskRows = [
{ name: "晨间生命体征记录", owner: "护理一组", status: "进行中", due: "09:30" },
{ name: "二楼失能老人巡检", owner: "护理二组", status: "待执行", due: "10:00" },
{ name: "高血压老人复测", owner: "医护组", status: "需复核", due: "10:30" },
{ name: "晚间护理计划生成", owner: "护理主管", status: "待派发", due: "16:00" },
];
export type DashboardIncident = {
id: string;
severity: IncidentSeverity;
status: string;
title: string;
source: string;
createdAt: string;
};
const alerts = [
{ label: "任务逾期", count: "4", tone: "warning" },
{ label: "设备故障", count: "2", tone: "danger" },
{ label: "应急处理中", count: "1", tone: "danger" },
{ label: "未读通知", count: "18", tone: "success" },
] as const;
type DashboardHomeProps = {
metrics: DashboardMetric[];
occupancyData: BedOccupancyDatum[];
recentAdmissions: DashboardAdmission[];
incidents: DashboardIncident[];
};
export function DashboardHome(): React.ReactElement {
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
users: Users,
beds: BedDouble,
admissions: CheckCircle2,
incidents: HeartPulse,
};
const admissionStatusLabels: Record<AdmissionStatus, string> = {
active: "在住",
transferred: "已换床",
discharged: "已退住",
};
const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲",
occupied: "占用",
maintenance: "维修",
disabled: "停用",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" | "danger" {
switch (severity) {
case "critical":
return "danger";
case "warning":
return "warning";
case "info":
return "secondary";
}
}
export function DashboardHome({
metrics,
occupancyData,
recentAdmissions,
incidents,
}: DashboardHomeProps): React.ReactElement {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<section className="grid gap-5 border-b pb-5 lg:grid-cols-[1.15fr_0.85fr]">
<div className="flex flex-col justify-end gap-4">
<Badge variant="success"></Badge>
<div>
<h1 className="text-3xl font-semibold tracking-normal md:text-4xl"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="flex flex-wrap gap-2">
<Button>
<Users className="size-4" aria-hidden="true" />
</Button>
<Button variant="outline">
<ShieldAlert className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="grid gap-3 rounded-lg border bg-white/72 p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"></p>
</div>
<Activity className="size-5 text-primary" aria-hidden="true" />
</div>
<div className="space-y-3">
{["档案", "床位", "健康", "护理", "应急"].map((item, index) => (
<div key={item} className="flex items-center gap-3">
<span className="w-9 text-xs font-medium text-muted-foreground">{item}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary"
style={{ width: `${88 - index * 9}%` }}
/>
</div>
<span className="w-14 text-right text-xs text-muted-foreground">
{88 - index * 9}%
</span>
</div>
))}
</div>
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<Badge variant="success"></Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal md:text-4xl"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.label}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardDescription>{metric.label}</CardDescription>
<metric.icon className="size-4 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent>
<CardTitle className="text-3xl">{metric.value}</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{metric.trend}</p>
</CardContent>
</Card>
))}
{metrics.map((metric) => {
const Icon = metricIcons[metric.icon];
return (
<Card key={metric.label}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardDescription>{metric.label}</CardDescription>
<Icon className="size-4 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent>
<CardTitle className="text-3xl">{metric.value}</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{metric.trend}</p>
</CardContent>
</Card>
);
})}
</section>
<section className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription>
{occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-md border">
<Table className="w-full text-sm">
<BedOccupancyChart data={occupancyData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[620px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{taskRows.map((task) => (
<Table.Row key={task.name}>
<Table.Cell className="px-4 py-3 font-medium">{task.name}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.owner}</Table.Cell>
{recentAdmissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.bedLabel}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={task.status === "需复核" ? "warning" : "secondary"}>
{task.status}
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
{admissionStatusLabels[admission.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">{task.due}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{formatDateTime(admission.dischargedAt ?? admission.admittedAt)}
</Table.Cell>
</Table.Row>
))}
{recentAdmissions.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={4}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alerts.map((alert) => (
<div key={alert.label} className="flex items-center justify-between rounded-md border p-3">
<div className="flex items-center gap-3">
{alert.tone === "danger" ? (
<AlertTriangle className="size-4 text-red-600" aria-hidden="true" />
) : (
<Clock3 className="size-4 text-amber-600" aria-hidden="true" />
)}
<span className="text-sm font-medium">{alert.label}</span>
</div>
<Badge variant={alert.tone}>{alert.count}</Badge>
</div>
))}
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{incidents.map((incident) => (
<div key={incident.id} className="flex items-start justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden="true" />
<p className="truncate text-sm font-medium">{incident.title}</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{incident.source} / {formatDateTime(incident.createdAt)}
</p>
</div>
<Badge variant={incidentVariant(incident.severity)}>{incident.status}</Badge>
</div>
))}
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
</div>
);
}

View File

@@ -439,7 +439,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
</Card>
<Dialog
description="保存后会写入服务端数据文件,并生成审计记录。"
description="保存后会写入 PostgreSQL,并生成审计记录。"
onClose={closeForm}
open={isFormOpen}
title={isEditing ? "编辑老人档案" : "新增老人档案"}

View File

@@ -0,0 +1,578 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { BedDouble, DoorOpen, History, MoveRight, UserCheck, UserMinus, Wrench } 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 { Textarea } from "@/components/ui/input";
import { Select, type SelectOption } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types";
import type { Elder } from "@/modules/elders/types";
type BedsWorkspaceProps = {
initialRooms: Room[];
initialBeds: FacilityBed[];
initialAdmissions: Admission[];
initialElders: Elder[];
permissions: Permission[];
};
type WorkspaceTab = "overview" | "operations" | "history";
const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲",
occupied: "占用",
maintenance: "维修",
disabled: "停用",
};
const admissionStatusLabels: Record<Admission["status"], string> = {
active: "在住",
transferred: "已换床",
discharged: "已退住",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function bedBadgeVariant(status: BedStatus): "success" | "secondary" | "warning" | "danger" {
switch (status) {
case "available":
return "success";
case "occupied":
return "secondary";
case "maintenance":
return "warning";
case "disabled":
return "danger";
}
}
function admissionBedLabel(admission: Admission): string {
return `${admission.roomName}-${admission.bedCode}`;
}
function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLabel: string): SelectOption[] {
return [
{ value: "", label: emptyLabel, disabled: true },
...items.map((item) => ({ value: item.id, label: item.label })),
];
}
export function BedsWorkspace({
initialRooms,
initialBeds,
initialAdmissions,
initialElders,
permissions,
}: BedsWorkspaceProps): React.ReactElement {
const [rooms, setRooms] = useState(initialRooms);
const [beds, setBeds] = useState(initialBeds);
const [admissions, setAdmissions] = useState(initialAdmissions);
const [elders, setElders] = useState(initialElders);
const [activeTab, setActiveTab] = useState<WorkspaceTab>("overview");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [admitElderId, setAdmitElderId] = useState("");
const [admitBedId, setAdmitBedId] = useState("");
const [admitNotes, setAdmitNotes] = useState("");
const [transferAdmissionId, setTransferAdmissionId] = useState("");
const [transferBedId, setTransferBedId] = useState("");
const [transferNotes, setTransferNotes] = useState("");
const [dischargeAdmissionId, setDischargeAdmissionId] = useState("");
const [dischargeNotes, setDischargeNotes] = useState("");
const canManageAdmissions = permissions.includes("admission:manage");
const activeAdmissions = useMemo(() => admissions.filter((admission) => admission.status === "active"), [admissions]);
const activeElderIds = useMemo(() => new Set(activeAdmissions.map((admission) => admission.elderId)), [activeAdmissions]);
const availableBeds = useMemo(() => beds.filter((bed) => bed.status === "available"), [beds]);
const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length;
const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
const admissionEligibleElders = elders.filter((elder) => !activeElderIds.has(elder.id));
const elderOptions = makeSelectOptions(
admissionEligibleElders.map((elder) => ({ id: elder.id, label: `${elder.name} / ${elder.phone}` })),
"选择老人",
);
const bedOptions = makeSelectOptions(
availableBeds.map((bed) => ({ id: bed.id, label: `${bed.roomName}-${bed.code}` })),
"选择空闲床位",
);
const activeAdmissionOptions = makeSelectOptions(
activeAdmissions.map((admission) => ({
id: admission.id,
label: `${admission.elderName} / ${admissionBedLabel(admission)}`,
})),
"选择在住记录",
);
async function refreshData(): Promise<void> {
const [eldersResponse, roomsResponse, bedsResponse, admissionsResponse] = await Promise.all([
fetch("/api/elders"),
fetch("/api/facilities/rooms"),
fetch("/api/facilities/beds"),
fetch("/api/admissions"),
]);
const [eldersResult, roomsResult, bedsResult, admissionsResult] = (await Promise.all([
eldersResponse.json(),
roomsResponse.json(),
bedsResponse.json(),
admissionsResponse.json(),
])) as [
ApiResult<{ elders: Elder[] }>,
ApiResult<{ rooms: Room[] }>,
ApiResult<{ beds: FacilityBed[] }>,
ApiResult<{ admissions: Admission[] }>,
];
if (eldersResult.success) {
setElders(eldersResult.elders);
}
if (roomsResult.success) {
setRooms(roomsResult.rooms);
}
if (bedsResult.success) {
setBeds(bedsResult.beds);
}
if (admissionsResult.success) {
setAdmissions(admissionsResult.admissions);
}
}
async function submitAdmission(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理入住");
return;
}
if (!admitElderId || !admitBedId) {
setMessage("请选择老人和空闲床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admitElderId, bedId: admitBedId, notes: admitNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setAdmitElderId("");
setAdmitBedId("");
setAdmitNotes("");
await refreshData();
setMessage(result.reason);
}
async function submitTransfer(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const admission = activeAdmissions.find((item) => item.id === transferAdmissionId);
if (!canManageAdmissions) {
setMessage("当前角色无权办理换床");
return;
}
if (!admission || !transferBedId) {
setMessage("请选择在住记录和新床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admission.elderId, bedId: transferBedId, notes: transferNotes || "办理换床" }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setTransferAdmissionId("");
setTransferBedId("");
setTransferNotes("");
await refreshData();
setMessage("换床已办理");
}
async function submitDischarge(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理退住");
return;
}
if (!dischargeAdmissionId) {
setMessage("请选择在住记录");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/admissions/${dischargeAdmissionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notes: dischargeNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setDischargeAdmissionId("");
setDischargeNotes("");
await refreshData();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<Badge variant="success"></Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground">退</p>
</div>
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位工作区">
{[
{ value: "overview", label: "概览" },
{ value: "operations", label: "办理" },
{ value: "history", label: "记录" },
].map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => setActiveTab(tab.value as WorkspaceTab)}
type="button"
variant={activeTab === tab.value ? "default" : "outline"}
>
{tab.label}
</Button>
))}
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{activeTab === "overview" ? (
<>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{rooms.length}</CardTitle>
</div>
<DoorOpen className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{beds.length}</CardTitle>
</div>
<BedDouble className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {occupancyRate}%</p>
</CardContent>
</Card>
<Card className={maintenanceBeds > 0 ? "border-amber-200 bg-amber-50/70" : undefined}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription className={maintenanceBeds > 0 ? "text-amber-700" : undefined}></CardDescription>
<CardTitle className={maintenanceBeds > 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}>
{maintenanceBeds}
</CardTitle>
</div>
<Wrench className={maintenanceBeds > 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" />
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{activeAdmissions.length}</CardTitle>
</div>
<History className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[620px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{rooms.map((room) => (
<Table.Row key={room.id}>
<Table.Cell className="px-4 py-3 font-medium">{room.name}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{room.code}</Table.Cell>
<Table.Cell className="px-4 py-3">{room.capacity}</Table.Cell>
<Table.Cell className="px-4 py-3">
{room.occupiedBedCount}/{room.bedCount}
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={room.status === "active" ? "success" : "danger"}>{room.status}</Badge>
</Table.Cell>
</Table.Row>
))}
{rooms.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[700px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{beds.map((bed) => (
<Table.Row key={bed.id}>
<Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">{bed.currentElderName ?? "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell>
</Table.Row>
))}
{beds.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
</section>
</>
) : null}
{activeTab === "operations" ? (
<section className="grid gap-4 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitAdmission(event)}>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitElderId}
options={elderOptions}
value={admitElderId}
/>
<Select
aria-label="入住房间床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitBedId}
options={bedOptions}
value={admitBedId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setAdmitNotes(event.target.value)}
placeholder="备注"
value={admitNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MoveRight className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitTransfer(event)}>
<Select
aria-label="换床入住记录"
disabled={!canManageAdmissions || isPending}
onValueChange={setTransferAdmissionId}
options={activeAdmissionOptions}
value={transferAdmissionId}
/>
<Select
aria-label="换床新床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setTransferBedId}
options={bedOptions}
value={transferBedId}
/>
<Textarea
aria-label="换床备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setTransferNotes(event.target.value)}
placeholder="备注"
value={transferNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="outline">
<MoveRight className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserMinus className="size-5 text-primary" aria-hidden="true" />
退
</CardTitle>
<CardDescription>退</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitDischarge(event)}>
<Select
aria-label="退住入住记录"
disabled={!canManageAdmissions || isPending}
onValueChange={setDischargeAdmissionId}
options={activeAdmissionOptions}
value={dischargeAdmissionId}
/>
<Textarea
aria-label="退住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setDischargeNotes(event.target.value)}
placeholder="备注"
value={dischargeNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="destructive">
<UserMinus className="size-4" aria-hidden="true" />
退
</Button>
</form>
</CardContent>
</Card>
</section>
) : null}
{activeTab === "history" ? (
<Card>
<CardHeader>
<CardTitle>退</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{admissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">{admissionBedLabel(admission)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
{admissionStatusLabels[admission.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(admission.admittedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{admission.dischargedAt ? formatDateTime(admission.dischargedAt) : "-"}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.notes || "-"}</Table.Cell>
</Table.Row>
))}
{admissions.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>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -6,7 +6,6 @@ import {
ClipboardCheck,
Globe2,
HeartPulse,
Home,
LayoutDashboard,
ListChecks,
Radio,
@@ -19,7 +18,6 @@ import {
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Permission, PublicAccount } from "@/modules/core/types";
import { SignOutButton } from "@/modules/auth/components/SignOutButton";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
@@ -149,7 +147,6 @@ type AppShellProps = {
};
export function AppShell({ children, account, permissions }: AppShellProps): React.ReactElement {
const canCreateElder = permissions.includes("elder:create");
const visibleNavGroups = navGroups
.map((group) => ({
...group,
@@ -216,10 +213,6 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
</div>
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
</div>
<Button size="sm" disabled={!canCreateElder}>
<Home className="size-4" aria-hidden="true" />
</Button>
<SignOutButton />
</div>
</header>