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"],
|
appointment: ["read", "write", "delete"],
|
||||||
prescription: ["read", "write", "delete"],
|
prescription: ["read", "write", "delete"],
|
||||||
task: ["read", "write", "delete"],
|
task: ["read", "write", "delete"],
|
||||||
|
lab: ["read", "write"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ac = createAccessControl(statements);
|
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({
|
export const owner = ac.newRole({
|
||||||
...ownerAc.statements,
|
...ownerAc.statements,
|
||||||
patient: ["read", "write", "delete"],
|
patient: ["read", "write", "delete"],
|
||||||
appointment: ["read", "write", "delete"],
|
appointment: ["read", "write", "delete"],
|
||||||
prescription: ["read", "write", "delete"],
|
prescription: ["read", "write", "delete"],
|
||||||
task: ["read", "write", "delete"],
|
task: ["read", "write", "delete"],
|
||||||
|
lab: ["read", "write"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const admin = ac.newRole({
|
export const admin = ac.newRole({
|
||||||
@@ -32,6 +37,7 @@ export const admin = ac.newRole({
|
|||||||
appointment: ["read", "write", "delete"],
|
appointment: ["read", "write", "delete"],
|
||||||
prescription: ["read", "write", "delete"],
|
prescription: ["read", "write", "delete"],
|
||||||
task: ["read", "write", "delete"],
|
task: ["read", "write", "delete"],
|
||||||
|
lab: ["read", "write"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const member = ac.newRole({
|
export const member = ac.newRole({
|
||||||
@@ -40,6 +46,7 @@ export const member = ac.newRole({
|
|||||||
appointment: ["read", "write", "delete"],
|
appointment: ["read", "write", "delete"],
|
||||||
prescription: ["read", "write", "delete"],
|
prescription: ["read", "write", "delete"],
|
||||||
task: ["read", "write", "delete"],
|
task: ["read", "write", "delete"],
|
||||||
|
lab: ["read", "write"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
|
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
|
||||||
@@ -50,6 +57,7 @@ export const doctor = ac.newRole({
|
|||||||
appointment: ["read", "write", "delete"],
|
appointment: ["read", "write", "delete"],
|
||||||
prescription: ["read", "write", "delete"],
|
prescription: ["read", "write", "delete"],
|
||||||
task: ["read", "write", "delete"],
|
task: ["read", "write", "delete"],
|
||||||
|
lab: ["read", "write"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// reception (front desk): scheduling + registration only, no clinical records.
|
// reception (front desk): scheduling + registration only, no clinical records.
|
||||||
@@ -60,14 +68,27 @@ export const reception = ac.newRole({
|
|||||||
task: ["read", "write"],
|
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"],
|
patient: ["read"],
|
||||||
appointment: ["read"],
|
appointment: ["read"],
|
||||||
prescription: ["read"],
|
prescription: ["read", "write"],
|
||||||
task: ["read"],
|
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.
|
// Human-readable labels for the role keys used in the UI.
|
||||||
export const ROLE_LABELS: Record<keyof typeof roles, string> = {
|
export const ROLE_LABELS: Record<keyof typeof roles, string> = {
|
||||||
@@ -75,6 +96,7 @@ export const ROLE_LABELS: Record<keyof typeof roles, string> = {
|
|||||||
admin: "Admin",
|
admin: "Admin",
|
||||||
doctor: "Doctor",
|
doctor: "Doctor",
|
||||||
reception: "Reception",
|
reception: "Reception",
|
||||||
|
pharmacy: "Pharmacy",
|
||||||
|
lab: "Lab",
|
||||||
member: "Clinician",
|
member: "Clinician",
|
||||||
viewer: "Viewer",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
"appointments": "Appointments",
|
"appointments": "Appointments",
|
||||||
"prescriptions": "Prescriptions",
|
"prescriptions": "Prescriptions",
|
||||||
"analysis": "Analysis",
|
"analysis": "Analysis",
|
||||||
|
"pharmacy": "Pharmacy",
|
||||||
|
"lab": "Lab",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
@@ -314,6 +316,66 @@
|
|||||||
"addedTitle": "Prescription added"
|
"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": {
|
"tasks": {
|
||||||
"title": "Tasks",
|
"title": "Tasks",
|
||||||
"subtitle": "Care-team to-dos. Click a task to see its details.",
|
"subtitle": "Care-team to-dos. Click a task to see its details.",
|
||||||
@@ -394,6 +456,9 @@
|
|||||||
"send": "Send",
|
"send": "Send",
|
||||||
"emptyTitle": "No conversation selected",
|
"emptyTitle": "No conversation selected",
|
||||||
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
|
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
|
||||||
|
"startConversation": "Start a conversation",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
"compose": {
|
"compose": {
|
||||||
"title": "New message",
|
"title": "New message",
|
||||||
"description": "Start a conversation with a member of your clinic.",
|
"description": "Start a conversation with a member of your clinic.",
|
||||||
|
|||||||
+33
-9
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
Cross,
|
||||||
|
FlaskConical,
|
||||||
History,
|
History,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -12,14 +14,22 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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 = {
|
export type NavSubItem = {
|
||||||
id: string;
|
id: string;
|
||||||
// i18n key resolved with t() at render time.
|
// i18n key resolved with t() at render time.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
link: string;
|
link: string;
|
||||||
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
|
// Hidden from roles outside this access area. See lib/roles.ts.
|
||||||
requiresClinical?: boolean;
|
access?: AccessArea;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
@@ -30,8 +40,8 @@ export type NavItem = {
|
|||||||
link: string;
|
link: string;
|
||||||
// Optional sub-pages revealed under this item in the sidebar.
|
// Optional sub-pages revealed under this item in the sidebar.
|
||||||
subs?: NavSubItem[];
|
subs?: NavSubItem[];
|
||||||
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
|
// Hidden from roles outside this access area. See lib/roles.ts.
|
||||||
requiresClinical?: boolean;
|
access?: AccessArea;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single source of truth for the primary navigation. Consumed by the sidebar
|
// Single source of truth for the primary navigation. Consumed by the sidebar
|
||||||
@@ -43,7 +53,7 @@ export const navItems: NavItem[] = [
|
|||||||
labelKey: "nav.newChat",
|
labelKey: "nav.newChat",
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
link: "/",
|
link: "/",
|
||||||
requiresClinical: true,
|
access: "clinical",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "patients",
|
id: "patients",
|
||||||
@@ -63,7 +73,7 @@ export const navItems: NavItem[] = [
|
|||||||
labelKey: "nav.prescriptions",
|
labelKey: "nav.prescriptions",
|
||||||
icon: Pill,
|
icon: Pill,
|
||||||
link: "/prescriptions",
|
link: "/prescriptions",
|
||||||
requiresClinical: true,
|
access: "clinical",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -72,7 +82,21 @@ export const navItems: NavItem[] = [
|
|||||||
labelKey: "nav.analysis",
|
labelKey: "nav.analysis",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
link: "/analysis",
|
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" },
|
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
|
||||||
{
|
{
|
||||||
@@ -80,7 +104,7 @@ export const navItems: NavItem[] = [
|
|||||||
labelKey: "nav.notes",
|
labelKey: "nav.notes",
|
||||||
icon: NotebookPen,
|
icon: NotebookPen,
|
||||||
link: "/notes",
|
link: "/notes",
|
||||||
requiresClinical: true,
|
access: "clinical",
|
||||||
},
|
},
|
||||||
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
|
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
|
||||||
{
|
{
|
||||||
@@ -88,7 +112,7 @@ export const navItems: NavItem[] = [
|
|||||||
labelKey: "nav.activity",
|
labelKey: "nav.activity",
|
||||||
icon: History,
|
icon: History,
|
||||||
link: "/activity",
|
link: "/activity",
|
||||||
requiresClinical: true,
|
access: "clinical",
|
||||||
},
|
},
|
||||||
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
|
{ 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 { type roles } from "@/lib/access";
|
||||||
import { authClient } from "@/lib/auth-client";
|
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;
|
export type RoleKey = keyof typeof roles;
|
||||||
|
|
||||||
@@ -14,12 +14,19 @@ export const PROVISIONABLE_ROLES: RoleKey[] = [
|
|||||||
"admin",
|
"admin",
|
||||||
"doctor",
|
"doctor",
|
||||||
"reception",
|
"reception",
|
||||||
"viewer",
|
"pharmacy",
|
||||||
|
"lab",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Departments a task can be assigned to (member roles). Mirrors the backend's
|
// Departments a task can be assigned to (member roles). Mirrors the backend's
|
||||||
// TASK_DEPARTMENTS in lib/task-validation.ts.
|
// 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
|
// 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.
|
// aren't a member). Re-fetches when the active organization changes.
|
||||||
@@ -52,6 +59,7 @@ export const CLINICAL_RESOURCES = [
|
|||||||
"appointment",
|
"appointment",
|
||||||
"prescription",
|
"prescription",
|
||||||
"task",
|
"task",
|
||||||
|
"lab",
|
||||||
] as const;
|
] as const;
|
||||||
const RESOURCE_ACTIONS = ["read", "write", "delete"] 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,
|
// Permission probes per access area, all derived from lib/access.ts so route
|
||||||
// analysis). Driven by Better Auth permissions so it stays in lock-step with
|
// gating can never drift from RBAC:
|
||||||
// lib/access.ts: the `reception` role has no `prescription` statement, so this
|
// - clinical: `prescription:delete` is held ONLY by full clinicians
|
||||||
// is false for them and true for every clinical role.
|
// (owner/admin/doctor/member) — pharmacy has read/write but not delete,
|
||||||
export function hasClinicalAccess(role: string | null | undefined): boolean {
|
// 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;
|
if (!role) return false;
|
||||||
try {
|
try {
|
||||||
return authClient.organization.checkRolePermission({
|
return authClient.organization.checkRolePermission({
|
||||||
role: role as RoleKey,
|
role: role as RoleKey,
|
||||||
permissions: { prescription: ["read"] },
|
permissions: AREA_PROBES[area],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where a role lands after sign-in. Reception has no AI chat, so they start on
|
// Whether a role may see clinical records (AI lookup, prescriptions, notes,
|
||||||
// the appointments board; clinical roles start on the chat home.
|
// analysis) — true for full clinicians (owner/admin/doctor/member) only.
|
||||||
export function defaultLandingFor(role: string | null | undefined): string {
|
export function hasClinicalAccess(role: string | null | undefined): boolean {
|
||||||
return hasClinicalAccess(role) ? "/" : "/appointments";
|
return canAccessArea(role, "clinical");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clinical-only routes — a non-clinical role (reception) is redirected away.
|
// Where a role lands after sign-in: clinicians on the chat home, pharmacy/lab
|
||||||
// Keyed by path; "/" matches exactly, others match themselves + nested paths.
|
// on their department dashboards, reception on the appointments board.
|
||||||
const CLINICAL_ROUTES = [
|
export function defaultLandingFor(role: string | null | undefined): string {
|
||||||
"/",
|
if (canAccessArea(role, "clinical")) return "/";
|
||||||
"/prescriptions",
|
if (canAccessArea(role, "pharmacy")) return "/pharmacy";
|
||||||
"/analysis",
|
if (canAccessArea(role, "lab")) return "/lab";
|
||||||
"/notes",
|
return "/appointments";
|
||||||
"/activity",
|
}
|
||||||
];
|
|
||||||
|
// 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
|
// 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
|
// loading to avoid redirect flicker; the authoritative check is the backend's
|
||||||
@@ -121,22 +153,26 @@ export function canAccessRoute(
|
|||||||
role: string | null | undefined,
|
role: string | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (role == null) return true;
|
if (role == null) return true;
|
||||||
if (hasClinicalAccess(role)) return true;
|
const match = Object.entries(ROUTE_AREAS).find(([route]) =>
|
||||||
return !CLINICAL_ROUTES.some((r) =>
|
route === "/" ? path === "/" : path === route || path.startsWith(`${route}/`),
|
||||||
r === "/" ? path === "/" : path === r || path.startsWith(`${r}/`),
|
|
||||||
);
|
);
|
||||||
|
if (!match) return true;
|
||||||
|
return canAccessArea(role, match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nav items visible to a role, with clinical-only items (and sub-items) removed
|
// Nav items visible to a role, with area-gated items (and sub-items) removed
|
||||||
// for non-clinical roles. While the role is loading we optimistically show
|
// 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.
|
// 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[] {
|
export function visibleNavItems(role: string | null | undefined): NavItem[] {
|
||||||
if (role == null) return navItems;
|
if (role == null) return navItems;
|
||||||
const clinical = hasClinicalAccess(role);
|
|
||||||
return navItems
|
return navItems
|
||||||
.filter((item) => !item.requiresClinical || clinical)
|
.filter((item) => !item.access || canAccessArea(role, item.access))
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...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
|
// 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
|
// 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 = {
|
export type Provider = {
|
||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user