From a4f692d59d92071f3719b83703e7540a8ceff7f5 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 11 Jun 2026 21:03:27 +0300 Subject: [PATCH] frontend: mirror pharmacy/lab roles with area-based route gating and nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/lib/access.ts | 32 ++++++- frontend/lib/i18n/locales/en/translation.json | 65 +++++++++++++ frontend/lib/nav.ts | 42 ++++++-- frontend/lib/roles.ts | 96 +++++++++++++------ frontend/lib/staff.ts | 2 +- 5 files changed, 192 insertions(+), 45 deletions(-) diff --git a/frontend/lib/access.ts b/frontend/lib/access.ts index 2cf0209..50ea38f 100644 --- a/frontend/lib/access.ts +++ b/frontend/lib/access.ts @@ -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 = { @@ -75,6 +96,7 @@ export const ROLE_LABELS: Record = { admin: "Admin", doctor: "Doctor", reception: "Reception", + pharmacy: "Pharmacy", + lab: "Lab", member: "Clinician", - viewer: "Viewer", }; diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index d2e9d05..eb8bac8 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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.", diff --git a/frontend/lib/nav.ts b/frontend/lib/nav.ts index 4273d3e..5e7413f 100644 --- a/frontend/lib/nav.ts +++ b/frontend/lib/nav.ts @@ -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" }, ]; diff --git a/frontend/lib/roles.ts b/frontend/lib/roles.ts index 964439b..6f75126 100644 --- a/frontend/lib/roles.ts +++ b/frontend/lib/roles.ts @@ -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 = { + 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 = { + "/": "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), + ), })); } diff --git a/frontend/lib/staff.ts b/frontend/lib/staff.ts index 5c61dff..c6f0f86 100644 --- a/frontend/lib/staff.ts +++ b/frontend/lib/staff.ts @@ -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;