mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
frontend: mirror pharmacy/lab roles with area-based route gating and nav
Mirror the backend role changes (drop viewer, add pharmacy + lab and the lab statement). Replace the binary requiresClinical gating with access areas (clinical / pharmacy / lab) probed from Better Auth permissions — prescription:delete marks full clinicians, prescription:write the pharmacy area, lab:write the lab area — so pharmacy doesn't inherit the AI chat or notes. Pharmacy and lab land on their new dashboards and get their own nav items; reception behavior is unchanged. Includes the i18n keys for the new pages and the messages polish that lands in the following commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+27
-5
@@ -14,16 +14,21 @@ export const statements = {
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
} as const;
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
|
||||
// NOTE: `prescription: ["delete"]` doubles as the "full clinician" marker the
|
||||
// route gating in lib/roles.ts probes (owner/admin/doctor/member only) — don't
|
||||
// grant it to department roles like pharmacy.
|
||||
export const owner = ac.newRole({
|
||||
...ownerAc.statements,
|
||||
patient: ["read", "write", "delete"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
});
|
||||
|
||||
export const admin = ac.newRole({
|
||||
@@ -32,6 +37,7 @@ export const admin = ac.newRole({
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
});
|
||||
|
||||
export const member = ac.newRole({
|
||||
@@ -40,6 +46,7 @@ export const member = ac.newRole({
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
});
|
||||
|
||||
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
|
||||
@@ -50,6 +57,7 @@ export const doctor = ac.newRole({
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
});
|
||||
|
||||
// reception (front desk): scheduling + registration only, no clinical records.
|
||||
@@ -60,14 +68,27 @@ export const reception = ac.newRole({
|
||||
task: ["read", "write"],
|
||||
});
|
||||
|
||||
export const viewer = ac.newRole({
|
||||
// pharmacy (dispensing): read patients/appointments, read/write prescriptions
|
||||
// (status updates, NOT delete), and work the task queue.
|
||||
export const pharmacy = ac.newRole({
|
||||
...memberAc.statements,
|
||||
patient: ["read"],
|
||||
appointment: ["read"],
|
||||
prescription: ["read"],
|
||||
task: ["read"],
|
||||
prescription: ["read", "write"],
|
||||
task: ["read", "write"],
|
||||
});
|
||||
|
||||
export const roles = { owner, admin, doctor, reception, member, viewer };
|
||||
// lab (analyses): submits lab results via the dedicated `lab` statement (no
|
||||
// patient:write) and works the lab task queue. No prescription statement.
|
||||
export const lab = ac.newRole({
|
||||
...memberAc.statements,
|
||||
patient: ["read"],
|
||||
appointment: ["read"],
|
||||
task: ["read", "write"],
|
||||
lab: ["read", "write"],
|
||||
});
|
||||
|
||||
export const roles = { owner, admin, doctor, reception, pharmacy, lab, member };
|
||||
|
||||
// Human-readable labels for the role keys used in the UI.
|
||||
export const ROLE_LABELS: Record<keyof typeof roles, string> = {
|
||||
@@ -75,6 +96,7 @@ export const ROLE_LABELS: Record<keyof typeof roles, string> = {
|
||||
admin: "Admin",
|
||||
doctor: "Doctor",
|
||||
reception: "Reception",
|
||||
pharmacy: "Pharmacy",
|
||||
lab: "Lab",
|
||||
member: "Clinician",
|
||||
viewer: "Viewer",
|
||||
};
|
||||
|
||||
@@ -114,6 +114,8 @@
|
||||
"appointments": "Appointments",
|
||||
"prescriptions": "Prescriptions",
|
||||
"analysis": "Analysis",
|
||||
"pharmacy": "Pharmacy",
|
||||
"lab": "Lab",
|
||||
"notes": "Notes",
|
||||
"messages": "Messages",
|
||||
"tasks": "Tasks",
|
||||
@@ -314,6 +316,66 @@
|
||||
"addedTitle": "Prescription added"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
"title": "Pharmacy",
|
||||
"subtitle": "Dispensing queue for the clinic's prescriptions.",
|
||||
"searchPlaceholder": "Search prescriptions",
|
||||
"kpi": {
|
||||
"active": "Active prescriptions",
|
||||
"expiring": "Expiring within 7 days",
|
||||
"patients": "Patients on medication"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Dispensing queue",
|
||||
"description": "Active prescriptions, oldest first. Mark a course completed once dispensed.",
|
||||
"empty": "No active prescriptions.",
|
||||
"noMatches": "No prescriptions match your search."
|
||||
},
|
||||
"expiringBadge": "Expiring",
|
||||
"markCompleted": "Mark completed",
|
||||
"completedTitle": "Prescription completed",
|
||||
"completedBody": "{{medication}} for {{name}} marked as completed.",
|
||||
"completeFailedTitle": "Couldn't update prescription",
|
||||
"completeFailedBody": "Please try again."
|
||||
},
|
||||
"lab": {
|
||||
"title": "Lab",
|
||||
"subtitle": "The lab's work queue and analysis results.",
|
||||
"queue": {
|
||||
"title": "Work queue",
|
||||
"description": "Tasks assigned to the Lab department.",
|
||||
"empty": "No lab tasks right now."
|
||||
},
|
||||
"addResult": {
|
||||
"button": "Add result",
|
||||
"title": "Add analysis result",
|
||||
"description": "Record an analysis performed in the lab on a patient's record.",
|
||||
"patient": "Patient",
|
||||
"patientPlaceholder": "Search by name or file number",
|
||||
"noPatients": "No patients match your search.",
|
||||
"changePatient": "Change",
|
||||
"test": "Test",
|
||||
"testPlaceholder": "e.g. Hemoglobin",
|
||||
"value": "Value",
|
||||
"valuePlaceholder": "e.g. 14.2 g/dL",
|
||||
"flag": "Flag",
|
||||
"takenAt": "Date",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add result",
|
||||
"needPatientTitle": "Pick a patient",
|
||||
"needPatientBody": "Search and select a patient first.",
|
||||
"needFieldsTitle": "Missing details",
|
||||
"needFieldsBody": "Enter the test name and its value.",
|
||||
"addedTitle": "Result added",
|
||||
"addedBody": "{{test}} added to {{name}}'s record.",
|
||||
"failedTitle": "Couldn't add the result",
|
||||
"failedBody": "Please try again."
|
||||
},
|
||||
"toast": {
|
||||
"updateFailedTitle": "Couldn't update the task",
|
||||
"updateFailedBody": "Please try again."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"subtitle": "Care-team to-dos. Click a task to see its details.",
|
||||
@@ -394,6 +456,9 @@
|
||||
"send": "Send",
|
||||
"emptyTitle": "No conversation selected",
|
||||
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
|
||||
"startConversation": "Start a conversation",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"compose": {
|
||||
"title": "New message",
|
||||
"description": "Start a conversation with a member of your clinic.",
|
||||
|
||||
+33
-9
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
Cross,
|
||||
FlaskConical,
|
||||
History,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
@@ -12,14 +14,22 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
// Access areas gate routes/nav by role capability (probed against the Better
|
||||
// Auth permissions in lib/access.ts — see canAccessArea in lib/roles.ts):
|
||||
// - "clinical": full clinicians only (owner/admin/doctor/member).
|
||||
// - "pharmacy": pharmacy + full clinicians.
|
||||
// - "lab": lab + full clinicians.
|
||||
// Defined here (not roles.ts) because roles.ts imports nav.ts.
|
||||
export type AccessArea = "clinical" | "pharmacy" | "lab";
|
||||
|
||||
export type NavSubItem = {
|
||||
id: string;
|
||||
// i18n key resolved with t() at render time.
|
||||
labelKey: string;
|
||||
icon?: LucideIcon;
|
||||
link: string;
|
||||
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
|
||||
requiresClinical?: boolean;
|
||||
// Hidden from roles outside this access area. See lib/roles.ts.
|
||||
access?: AccessArea;
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
@@ -30,8 +40,8 @@ export type NavItem = {
|
||||
link: string;
|
||||
// Optional sub-pages revealed under this item in the sidebar.
|
||||
subs?: NavSubItem[];
|
||||
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
|
||||
requiresClinical?: boolean;
|
||||
// Hidden from roles outside this access area. See lib/roles.ts.
|
||||
access?: AccessArea;
|
||||
};
|
||||
|
||||
// Single source of truth for the primary navigation. Consumed by the sidebar
|
||||
@@ -43,7 +53,7 @@ export const navItems: NavItem[] = [
|
||||
labelKey: "nav.newChat",
|
||||
icon: Plus,
|
||||
link: "/",
|
||||
requiresClinical: true,
|
||||
access: "clinical",
|
||||
},
|
||||
{
|
||||
id: "patients",
|
||||
@@ -63,7 +73,7 @@ export const navItems: NavItem[] = [
|
||||
labelKey: "nav.prescriptions",
|
||||
icon: Pill,
|
||||
link: "/prescriptions",
|
||||
requiresClinical: true,
|
||||
access: "clinical",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -72,7 +82,21 @@ export const navItems: NavItem[] = [
|
||||
labelKey: "nav.analysis",
|
||||
icon: BarChart3,
|
||||
link: "/analysis",
|
||||
requiresClinical: true,
|
||||
access: "clinical",
|
||||
},
|
||||
{
|
||||
id: "pharmacy",
|
||||
labelKey: "nav.pharmacy",
|
||||
icon: Cross,
|
||||
link: "/pharmacy",
|
||||
access: "pharmacy",
|
||||
},
|
||||
{
|
||||
id: "lab",
|
||||
labelKey: "nav.lab",
|
||||
icon: FlaskConical,
|
||||
link: "/lab",
|
||||
access: "lab",
|
||||
},
|
||||
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
|
||||
{
|
||||
@@ -80,7 +104,7 @@ export const navItems: NavItem[] = [
|
||||
labelKey: "nav.notes",
|
||||
icon: NotebookPen,
|
||||
link: "/notes",
|
||||
requiresClinical: true,
|
||||
access: "clinical",
|
||||
},
|
||||
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
|
||||
{
|
||||
@@ -88,7 +112,7 @@ export const navItems: NavItem[] = [
|
||||
labelKey: "nav.activity",
|
||||
icon: History,
|
||||
link: "/activity",
|
||||
requiresClinical: true,
|
||||
access: "clinical",
|
||||
},
|
||||
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
|
||||
];
|
||||
|
||||
+66
-30
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { type roles } from "@/lib/access";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { type NavItem, navItems } from "@/lib/nav";
|
||||
import { type AccessArea, type NavItem, navItems } from "@/lib/nav";
|
||||
|
||||
export type RoleKey = keyof typeof roles;
|
||||
|
||||
@@ -14,12 +14,19 @@ export const PROVISIONABLE_ROLES: RoleKey[] = [
|
||||
"admin",
|
||||
"doctor",
|
||||
"reception",
|
||||
"viewer",
|
||||
"pharmacy",
|
||||
"lab",
|
||||
];
|
||||
|
||||
// Departments a task can be assigned to (member roles). Mirrors the backend's
|
||||
// TASK_DEPARTMENTS in lib/task-validation.ts.
|
||||
export const DEPARTMENTS = ["admin", "doctor", "reception"] as const;
|
||||
export const DEPARTMENTS = [
|
||||
"admin",
|
||||
"doctor",
|
||||
"reception",
|
||||
"pharmacy",
|
||||
"lab",
|
||||
] as const;
|
||||
|
||||
// The current user's role in the active clinic (null while loading or if they
|
||||
// aren't a member). Re-fetches when the active organization changes.
|
||||
@@ -52,6 +59,7 @@ export const CLINICAL_RESOURCES = [
|
||||
"appointment",
|
||||
"prescription",
|
||||
"task",
|
||||
"lab",
|
||||
] as const;
|
||||
const RESOURCE_ACTIONS = ["read", "write", "delete"] as const;
|
||||
|
||||
@@ -81,37 +89,61 @@ export function rolePermissionSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a role may see clinical records (AI lookup, prescriptions, notes,
|
||||
// analysis). Driven by Better Auth permissions so it stays in lock-step with
|
||||
// lib/access.ts: the `reception` role has no `prescription` statement, so this
|
||||
// is false for them and true for every clinical role.
|
||||
export function hasClinicalAccess(role: string | null | undefined): boolean {
|
||||
// Permission probes per access area, all derived from lib/access.ts so route
|
||||
// gating can never drift from RBAC:
|
||||
// - clinical: `prescription:delete` is held ONLY by full clinicians
|
||||
// (owner/admin/doctor/member) — pharmacy has read/write but not delete,
|
||||
// reception/lab no prescription statement at all.
|
||||
// - pharmacy: `prescription:write` (pharmacy + full clinicians).
|
||||
// - lab: `lab:write` (lab + full clinicians).
|
||||
const AREA_PROBES: Record<AccessArea, PermissionArg> = {
|
||||
clinical: { prescription: ["delete"] } as PermissionArg,
|
||||
pharmacy: { prescription: ["write"] } as PermissionArg,
|
||||
lab: { lab: ["write"] } as PermissionArg,
|
||||
};
|
||||
|
||||
// Whether a role belongs to an access area (see AccessArea in lib/nav.ts).
|
||||
export function canAccessArea(
|
||||
role: string | null | undefined,
|
||||
area: AccessArea,
|
||||
): boolean {
|
||||
if (!role) return false;
|
||||
try {
|
||||
return authClient.organization.checkRolePermission({
|
||||
role: role as RoleKey,
|
||||
permissions: { prescription: ["read"] },
|
||||
permissions: AREA_PROBES[area],
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Where a role lands after sign-in. Reception has no AI chat, so they start on
|
||||
// the appointments board; clinical roles start on the chat home.
|
||||
export function defaultLandingFor(role: string | null | undefined): string {
|
||||
return hasClinicalAccess(role) ? "/" : "/appointments";
|
||||
// Whether a role may see clinical records (AI lookup, prescriptions, notes,
|
||||
// analysis) — true for full clinicians (owner/admin/doctor/member) only.
|
||||
export function hasClinicalAccess(role: string | null | undefined): boolean {
|
||||
return canAccessArea(role, "clinical");
|
||||
}
|
||||
|
||||
// Clinical-only routes — a non-clinical role (reception) is redirected away.
|
||||
// Keyed by path; "/" matches exactly, others match themselves + nested paths.
|
||||
const CLINICAL_ROUTES = [
|
||||
"/",
|
||||
"/prescriptions",
|
||||
"/analysis",
|
||||
"/notes",
|
||||
"/activity",
|
||||
];
|
||||
// Where a role lands after sign-in: clinicians on the chat home, pharmacy/lab
|
||||
// on their department dashboards, reception on the appointments board.
|
||||
export function defaultLandingFor(role: string | null | undefined): string {
|
||||
if (canAccessArea(role, "clinical")) return "/";
|
||||
if (canAccessArea(role, "pharmacy")) return "/pharmacy";
|
||||
if (canAccessArea(role, "lab")) return "/lab";
|
||||
return "/appointments";
|
||||
}
|
||||
|
||||
// Area-gated routes — a role outside the area is redirected away. Keyed by
|
||||
// path; "/" matches exactly, others match themselves + nested paths.
|
||||
const ROUTE_AREAS: Record<string, AccessArea> = {
|
||||
"/": "clinical",
|
||||
"/prescriptions": "clinical",
|
||||
"/analysis": "clinical",
|
||||
"/notes": "clinical",
|
||||
"/activity": "clinical",
|
||||
"/pharmacy": "pharmacy",
|
||||
"/lab": "lab",
|
||||
};
|
||||
|
||||
// Whether `path` is reachable by `role`. Returns true while the role is still
|
||||
// loading to avoid redirect flicker; the authoritative check is the backend's
|
||||
@@ -121,22 +153,26 @@ export function canAccessRoute(
|
||||
role: string | null | undefined,
|
||||
): boolean {
|
||||
if (role == null) return true;
|
||||
if (hasClinicalAccess(role)) return true;
|
||||
return !CLINICAL_ROUTES.some((r) =>
|
||||
r === "/" ? path === "/" : path === r || path.startsWith(`${r}/`),
|
||||
const match = Object.entries(ROUTE_AREAS).find(([route]) =>
|
||||
route === "/" ? path === "/" : path === route || path.startsWith(`${route}/`),
|
||||
);
|
||||
if (!match) return true;
|
||||
return canAccessArea(role, match[1]);
|
||||
}
|
||||
|
||||
// Nav items visible to a role, with clinical-only items (and sub-items) removed
|
||||
// for non-clinical roles. While the role is loading we optimistically show
|
||||
// Nav items visible to a role, with area-gated items (and sub-items) removed
|
||||
// for roles outside the area. While the role is loading we optimistically show
|
||||
// everything (clinical users are the common case) — the flash is sub-second.
|
||||
// Full clinicians pass every area probe, so they also see the Pharmacy and Lab
|
||||
// items (intentional oversight access).
|
||||
export function visibleNavItems(role: string | null | undefined): NavItem[] {
|
||||
if (role == null) return navItems;
|
||||
const clinical = hasClinicalAccess(role);
|
||||
return navItems
|
||||
.filter((item) => !item.requiresClinical || clinical)
|
||||
.filter((item) => !item.access || canAccessArea(role, item.access))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
subs: item.subs?.filter((sub) => !sub.requiresClinical || clinical),
|
||||
subs: item.subs?.filter(
|
||||
(sub) => !sub.access || canAccessArea(role, sub.access),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
// A clinician who can be assigned as a patient's primary provider. Returned by
|
||||
// the backend's GET /api/staff/providers (clinical roles only — excludes
|
||||
// reception/viewer). Readable by any clinic member.
|
||||
// reception/pharmacy/lab). Readable by any clinic member.
|
||||
export type Provider = {
|
||||
userId: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user