diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 0f86815..8532f87 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -154,8 +154,27 @@ Undo behaviour: start/complete are applied optimistically and can be reverted wi - `POST /consultation-notes/:id/addenda` (body: `{content}`) → `ConsultationAddendum` — append-only; no edit/delete UI for addenda. - Auto sign-off deadline, if present, is exposed as `autoSignOffAt` on the note. +#### Doctor practice insights (issue #50, depends on docforum-core#124) +- `GET /doctor/insights?preset=&startDate=&endDate=` → `DoctorPracticeInsights` +- Scoped strictly to the authenticated doctor's own practice data; peer-comparison suppression guarantees no benchmarking against other named doctors. +- Aggregates weekly slot utilisation (offered vs booked), no-show and late-cancellation (<24h) rates, referral acceptance turnaround (average, median, distribution), and message response time (within 2h/24h). +- Small-sample caveats (`sampleSizeWarning: true`) automatically flagged when denominators are low (total slots < 10, referrals < 5, message threads < 5) to warn against misleading rate volatility. +- Every chart view has a full accessible `` alternative with semantic captions, headers, and tabular numeric alignment. + +### Phase W4 — Facility staff accounts & fulfillment attribution (issue #51, docforum-core#75) +- `GET /facilities/:facilityId/members` → `FacilityMember[]` — facility staff and owners. +- `GET /facilities/:facilityId/invites` → `FacilityInvite[]` — pending invitations (owner-only access; hidden for staff). +- `POST /facilities/:facilityId/invites` (body: `{email, role: 'owner'|'staff'}`) → `FacilityInvite` — invite staff by email (owner-only). +- `POST /facilities/:facilityId/invites/:inviteId/resend` → `FacilityInvite` — resend invitation email (owner-only). +- `DELETE /facilities/:facilityId/invites/:inviteId` → `{success: boolean}` — revoke invite (owner-only). +- `DELETE /facilities/:facilityId/members/:memberId` → `RemoveMemberResponse` — remove member and immediately invalidate/revoke their active backend sessions (owner-only; cannot remove the last owner of a facility). +- Non-owner staff users see the team page in read-only mode (invite, revoke, and remove actions are hidden and rejected if forced). +- `GET /facilities/:facilityId/fulfillment-queue` → `FulfillmentQueueItem[]` — queue items with action attribution (`attribution: { action, performedByUserId, performedByName, performedByRole, timestamp, notes }`). +- `POST /facilities/:facilityId/fulfillment-queue/:itemId/action` (body: `{action: 'fulfilled' | 'rejected', notes?}`) → `FulfillmentQueueItem` — records the acting staff user's identity and role for PRD §7 audit trail. + ## Roadmap / status See `ROADMAP.md` in this repo, and Phase 7 in `docforum-core`'s `ROADMAP.md` for the original (pre-split) frontend phase this was carved out of. + diff --git a/ROADMAP.md b/ROADMAP.md index 731a228..896bcca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,9 +56,11 @@ real API" (no refresh-on-reload flow). - [x] Doctor live queue: start and complete visits, running delay with manual override and undo window. Tracked as [issue #49](https://github.com/DocForum/docforum-web/issues/49) (High, 200 pts) — `src/features/queue/`, `QueuePanel`, optimistic start/complete with a 4s undo toast. - [x] Doctor messages inbox across all patient threads. Tracked as [issue #47](https://github.com/DocForum/docforum-web/issues/47) (High, 200 pts) — `src/features/messaging/`, `DoctorInbox`, `UnreadBadge`, unread counts, truncated PHI previews, oldest-unanswered-first sorting, filters, pagination. - [x] Doctor command palette and keyboard shortcuts. Tracked as [issue #46](https://github.com/DocForum/docforum-web/issues/46) (High, 200 pts) — `src/components/CommandPalette.tsx`, `src/hooks/useKeyboardShortcuts.ts`, shortcuts help dialog, input suppression. +- [x] Doctor practice insights: utilisation, no-shows and turnaround. Tracked as [issue #50](https://github.com/DocForum/docforum-web/issues/50) (High, 200 pts) — `src/features/insights/`, `DoctorPracticeInsightsPage`, slot utilisation by week, no-show/late-cancel rates, referral turnaround, message response time; charts with accessible table alternatives; date range selector; small-sample caveats; doctor-only data (no peer comparisons). ## Phase W4 — Facility core flow -**Fully scoped into issue #8.** +**Fully scoped into issues #8 and #51.** +- [x] Facility staff accounts: invite, list and remove. Tracked as [issue #51](https://github.com/DocForum/docforum-web/issues/51) (High, 200 pts) — `src/features/facility-team/`, `FacilityTeamPage`, owner staff invite by email with fixed roles, pending invites with resend/revoke, member removal with backend session revocation and last-owner protection, read-only staff view, and fulfillment queue action attribution (PRD §7). - [ ] Fulfillment queue. Tracked as [issue #8](https://github.com/DocForum/docforum-web/issues/8) (Medium, 150 pts). - [ ] Mark fulfilled/rejected, attach lab result. Folded into issue #8 above. @@ -350,4 +352,9 @@ real API" (no refresh-on-reload flow). (`/doctor/consultation/:id`, `/doctor/messages`). Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" with the Phase W3 assumed API shapes. Typecheck, build, and all non-pre-existing tests pass. +- 2026-10-18 — Built Phase W3 Doctor practice insights (#50) and Phase W4 Facility staff accounts with fulfillment attribution (#51): + - `#50 Doctor practice insights`: private insights page (`/doctor/insights`) with weekly slot utilisation, no-show and late-cancellation rates, referral acceptance turnaround, and messages response time. Includes SVG charts paired with accessible semantic `
` alternatives, date range selector (presets + custom), clearly labelled small-sample caveats, and strict peer-comparison suppression (doctor's own confidential data only). Wired into `DoctorDashboardPage`, command palette (`⌘K`), and navigation. + - `#51 Facility staff accounts & fulfillment attribution`: dedicated team management page (`/facility/team`) with member list, role assignment (`owner` vs `staff`), email invitations, resend/revoke pending invites, member removal with backend session revocation warning, and last-owner deletion protection. Enforced read-only view and action suppression for staff members. In the fulfillment queue (`/facility`), added action attribution display showing which staff member dispensed/fulfilled each order for PRD §7 audit trail. + - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 24 test files and 112/112 tests passing; typecheck and build green. + diff --git a/screenshots/doctor-dashboard-desktop.png b/screenshots/doctor-dashboard-desktop.png new file mode 100644 index 0000000..06e0471 Binary files /dev/null and b/screenshots/doctor-dashboard-desktop.png differ diff --git a/screenshots/doctor-practice-insights-desktop.png b/screenshots/doctor-practice-insights-desktop.png new file mode 100644 index 0000000..9403ab0 Binary files /dev/null and b/screenshots/doctor-practice-insights-desktop.png differ diff --git a/screenshots/doctor-practice-insights-mobile.png b/screenshots/doctor-practice-insights-mobile.png new file mode 100644 index 0000000..efac539 Binary files /dev/null and b/screenshots/doctor-practice-insights-mobile.png differ diff --git a/screenshots/facility-fulfillment-queue-desktop.png b/screenshots/facility-fulfillment-queue-desktop.png new file mode 100644 index 0000000..eba8b99 Binary files /dev/null and b/screenshots/facility-fulfillment-queue-desktop.png differ diff --git a/screenshots/facility-fulfillment-queue-mobile.png b/screenshots/facility-fulfillment-queue-mobile.png new file mode 100644 index 0000000..b557dd7 Binary files /dev/null and b/screenshots/facility-fulfillment-queue-mobile.png differ diff --git a/screenshots/facility-team-owner-desktop.png b/screenshots/facility-team-owner-desktop.png new file mode 100644 index 0000000..ee921c6 Binary files /dev/null and b/screenshots/facility-team-owner-desktop.png differ diff --git a/screenshots/facility-team-owner-mobile.png b/screenshots/facility-team-owner-mobile.png new file mode 100644 index 0000000..122f6f7 Binary files /dev/null and b/screenshots/facility-team-owner-mobile.png differ diff --git a/src/App.tsx b/src/App.tsx index 45eac4e..43aa5e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ import { AccountDeletedPage } from './pages/AccountDeletedPage'; import { NotFoundPage } from './pages/NotFoundPage'; import { ConsultationNote } from './features/consultation/components/ConsultationNote'; import { DoctorInbox } from './features/messaging/components/DoctorInbox'; +import { DoctorPracticeInsightsPage } from './pages/DoctorPracticeInsightsPage'; +import { FacilityTeamPage } from './pages/FacilityTeamPage'; export default function App() { return ( @@ -45,6 +47,11 @@ export default function App() { } /> + + + + } /> @@ -55,6 +62,14 @@ export default function App() { } /> + + + + } + /> )} + {user.role === 'doctor' && ( + <> + + Dashboard + + + Insights + + + )} + {user.role === 'facility' && ( + <> + + Queue + + + Team + + + )} {user.email} {user.role === 'doctor' && } diff --git a/src/features/facility-team/components/FacilityMemberList.tsx b/src/features/facility-team/components/FacilityMemberList.tsx new file mode 100644 index 0000000..be2d945 --- /dev/null +++ b/src/features/facility-team/components/FacilityMemberList.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Button } from '../../../components/Button'; +import { RemoveMemberModal } from './RemoveMemberModal'; +import type { FacilityMember } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +interface FacilityMemberListProps { + members: FacilityMember[]; + isOwner: boolean; + onRemoveMember: (memberId: string) => Promise; + isRemoving: boolean; +} + +export function FacilityMemberList({ + members, + isOwner, + onRemoveMember, + isRemoving, +}: FacilityMemberListProps) { + const [selectedMember, setSelectedMember] = useState(null); + + const ownerCount = members.filter((m) => m.role === 'owner').length; + + return ( +
+
+
+

Facility members

+

+ Current staff and owners with access to this facility. +

+
+
+ +
+
+ + + + + + + {isOwner && } + + + + {members.map((m) => { + const isLastOwner = m.role === 'owner' && ownerCount <= 1; + + return ( + + + + + + {isOwner && ( + + )} + + ); + })} + +
NameEmailRoleJoinedActions
+ {m.fullName} + {m.isCurrentUser && You} + {m.email} + + {m.role} + + {new Date(m.joinedAt).toLocaleDateString()} + +
+ + + setSelectedMember(null)} + onConfirm={onRemoveMember} + isPending={isRemoving} + isLastOwner={Boolean(selectedMember?.role === 'owner' && ownerCount <= 1)} + isOwner={isOwner} + /> + + ); +} diff --git a/src/features/facility-team/components/FacilityTeam.module.css b/src/features/facility-team/components/FacilityTeam.module.css new file mode 100644 index 0000000..a70ad6f --- /dev/null +++ b/src/features/facility-team/components/FacilityTeam.module.css @@ -0,0 +1,294 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-6); + width: 100%; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-4); +} + +.headerTitle h1 { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.headerSubtitle { + margin: var(--space-1) 0 0 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.roleNotice { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: var(--text-sm); +} + +.roleNoticeOwner { + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.roleNoticeStaff { + background: #fdf3d8; + border: 1px solid #ebd08c; + color: #8c5b00; +} + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + box-shadow: var(--shadow-sm); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +.cardHeader h2 { + margin: 0; + font-size: var(--text-xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.tableWrapper { + width: 100%; + overflow-x: auto; +} + +.dataTable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); + text-align: left; +} + +.dataTable th, +.dataTable td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.dataTable th { + background: var(--color-surface-alt); + color: var(--color-text); + font-weight: 600; +} + +.dataTable tr:hover td { + background: var(--color-surface-hover); +} + +.memberRoleBadge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; + text-transform: capitalize; +} + +.badgeOwner { + background: #fdf3d8; + color: #8c5b00; + border: 1px solid #ebd08c; +} + +.badgeStaff { + background: #eef6f2; + color: var(--color-accent); + border: 1px solid #b8dfcb; +} + +.youBadge { + display: inline-block; + margin-left: var(--space-2); + padding: 0.1rem 0.4rem; + border-radius: var(--radius-full); + font-size: var(--text-xs); + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + color: var(--color-text-muted); +} + +.actionCell { + text-align: right; + white-space: nowrap; +} + +.buttonGroup { + display: inline-flex; + gap: var(--space-2); +} + +/* Modal / Dialog */ +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(28, 24, 21, 0.45); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + z-index: 100; +} + +.modalContent { + background: var(--color-bg-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + max-width: 480px; + width: 100%; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.modalContent h3 { + margin: 0; + font-size: var(--text-xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.modalDescription { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: var(--leading-normal); +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-2); +} + +/* Queue with Attribution */ +.queueItem { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + background: var(--color-surface-alt); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.queueItemHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-2); +} + +.orderTitle { + font-weight: 600; + font-size: var(--text-base); + color: var(--color-text); +} + +.orderMeta { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.attributionBox { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: var(--color-text); +} + +.attributionActor { + font-weight: 600; + color: var(--color-accent); +} + +.statusPill { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; + text-transform: capitalize; +} + +.statusPending { + background: #fff8e6; + color: #8c5b00; + border: 1px solid #ebd08c; +} + +.statusFulfilled { + background: #eef6f2; + color: var(--color-accent); + border: 1px solid #b8dfcb; +} + +.statusRejected { + background: #fdf2f2; + color: #b3261e; + border: 1px solid #f2b8b5; +} + +.emptyState { + padding: var(--space-8); + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.tabsNav { + display: flex; + gap: var(--space-2); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-2); +} + +.tabLink { + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-weight: 500; + text-decoration: none; + color: var(--color-text-muted); +} + +.tabLinkActive { + background: var(--color-surface-hover); + color: var(--color-text); + font-weight: 600; +} diff --git a/src/features/facility-team/components/FacilityTeam.test.tsx b/src/features/facility-team/components/FacilityTeam.test.tsx new file mode 100644 index 0000000..b209ef7 --- /dev/null +++ b/src/features/facility-team/components/FacilityTeam.test.tsx @@ -0,0 +1,202 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../test/render'; +import { FacilityTeamView, DEFAULT_MOCK_MEMBERS, DEFAULT_MOCK_INVITES } from './FacilityTeamView'; +import { FulfillmentQueueWithAttribution } from './FulfillmentQueueWithAttribution'; +import { DEFAULT_MOCK_QUEUE } from '../../../pages/FacilityDashboardPage'; +import { useAuthStore } from '../../../store/auth-store'; +import * as teamService from '../../../services/facility-team-service'; + +vi.mock('../../../services/facility-team-service'); + +beforeEach(() => { + useAuthStore.setState({ + accessToken: 'test-token', + user: { id: 'user-owner-1', email: 'evelyn.reed@centralpharmacy.org', role: 'facility', createdAt: '', updatedAt: '' }, + }); + + vi.mocked(teamService.listFacilityMembers).mockResolvedValue(DEFAULT_MOCK_MEMBERS); + vi.mocked(teamService.listFacilityInvites).mockResolvedValue(DEFAULT_MOCK_INVITES); + vi.mocked(teamService.inviteFacilityMember).mockResolvedValue({ + id: 'inv-new', + facilityId: 'fac-main', + email: 'newbie@centralpharmacy.org', + role: 'staff', + invitedBy: 'evelyn.reed@centralpharmacy.org', + invitedAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + status: 'pending', + }); + vi.mocked(teamService.removeFacilityMember).mockResolvedValue({ + success: true, + memberId: 'mem-2', + sessionsRevoked: true, + message: 'Member removed and sessions revoked', + }); + vi.mocked(teamService.resendFacilityInvite).mockResolvedValue({ + ...DEFAULT_MOCK_INVITES[0], + invitedAt: new Date().toISOString(), + }); + vi.mocked(teamService.revokeFacilityInvite).mockResolvedValue({ success: true }); +}); + +describe('Facility Staff Accounts (#51)', () => { + describe('Facility Owner View', () => { + it('renders owner view with member list, roles, and invite button', async () => { + render(); + + await screen.findByRole('heading', { name: /facility team & accounts/i }); + + // Owner administrative banner + expect(screen.getByText(/Facility Owner Access/i)).toBeInTheDocument(); + + // Invite button is visible to owners + expect(screen.getByRole('button', { name: /\+ invite staff member/i })).toBeInTheDocument(); + + // Verify members are listed with their roles + expect(screen.getByText('Dr. Evelyn Reed')).toBeInTheDocument(); + expect(screen.getByText('Marcus Vance, PharmD')).toBeInTheDocument(); + expect(screen.getByText('Aisha Patel')).toBeInTheDocument(); + + // Verify pending invitations table is displayed + expect(screen.getByRole('heading', { name: /pending invitations/i })).toBeInTheDocument(); + expect(screen.getByText('david.kim@centralpharmacy.org')).toBeInTheDocument(); + }); + + it('opens invite modal and submits a staff invitation', async () => { + render(); + + const inviteBtn = await screen.findByRole('button', { name: /\+ invite staff member/i }); + await userEvent.click(inviteBtn); + + const modalHeading = screen.getByRole('heading', { name: /invite team member/i }); + expect(modalHeading).toBeInTheDocument(); + + const emailInput = screen.getByLabelText(/email address/i); + await userEvent.type(emailInput, 'newbie@centralpharmacy.org'); + + const submitBtn = screen.getByRole('button', { name: /^send invite$/i }); + await userEvent.click(submitBtn); + + await waitFor(() => { + expect(teamService.inviteFacilityMember).toHaveBeenCalledWith( + 'fac-main', + { email: 'newbie@centralpharmacy.org', role: 'staff' }, + 'test-token', + ); + }); + }); + + it('prevents removing the last owner and displays appropriate protection', async () => { + render(); + + await screen.findByRole('heading', { name: /facility members/i }); + + // In DEFAULT_MOCK_MEMBERS, Evelyn Reed is the only owner (1 owner total) + const removeReedBtn = screen.getByRole('button', { name: /remove member dr\. evelyn reed/i }); + expect(removeReedBtn).toBeDisabled(); + expect(removeReedBtn).toHaveAttribute('title', 'Cannot remove the last owner of a facility'); + + // Staff member removal button is enabled + const removeVanceBtn = screen.getByRole('button', { name: /remove member marcus vance/i }); + expect(removeVanceBtn).not.toBeDisabled(); + }); + + it('opens removal confirmation with session revocation warning and proceeds', async () => { + render(); + + await screen.findByRole('heading', { name: /facility members/i }); + + const removeVanceBtn = screen.getByRole('button', { name: /remove member marcus vance/i }); + await userEvent.click(removeVanceBtn); + + // Verify modal description and session revocation warning + expect(screen.getByRole('heading', { name: /remove team member/i })).toBeInTheDocument(); + expect(screen.getByText(/Removing a member immediately signs them out and revokes all active backend sessions/i)).toBeInTheDocument(); + + // Confirm removal + const confirmBtn = screen.getByRole('button', { name: /^remove member$/i }); + await userEvent.click(confirmBtn); + + await waitFor(() => { + expect(teamService.removeFacilityMember).toHaveBeenCalledWith('fac-main', 'mem-2', 'test-token'); + }); + }); + + it('allows resending and revoking pending invitations', async () => { + render(); + + const resendBtn = await screen.findByRole('button', { name: /resend invite to david\.kim/i }); + await userEvent.click(resendBtn); + expect(teamService.resendFacilityInvite).toHaveBeenCalledWith('fac-main', 'inv-1', 'test-token'); + + const revokeBtn = screen.getByRole('button', { name: /revoke invite for david\.kim/i }); + await userEvent.click(revokeBtn); + expect(teamService.revokeFacilityInvite).toHaveBeenCalledWith('fac-main', 'inv-1', 'test-token'); + }); + }); + + describe('Staff View (Read-Only)', () => { + it('hides owner-only actions (invite, remove, pending invites) for staff users', async () => { + render(); + + await screen.findByRole('heading', { name: /facility team & accounts/i }); + + // Displays read-only staff notice + expect(screen.getByText(/Staff Member View \(Read-only\)/i)).toBeInTheDocument(); + + // Owner-only actions MUST be hidden + expect(screen.queryByRole('button', { name: /\+ invite staff member/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove member/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /pending invitations/i })).not.toBeInTheDocument(); + + // Read-only member list is still visible + expect(screen.getByText('Dr. Evelyn Reed')).toBeInTheDocument(); + expect(screen.getByText('Marcus Vance, PharmD')).toBeInTheDocument(); + }); + }); + + describe('Fulfillment Queue with Action Attribution', () => { + it('displays who performed each fulfillment action in the queue', () => { + const onAction = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + // Verify fulfilled prescription shows dispensing doctor attribution + expect(screen.getByText('Amoxicillin 500mg (30 capsules)')).toBeInTheDocument(); + expect(screen.getByText('Dr. Evelyn Reed')).toBeInTheDocument(); + expect(screen.getByText(/Dispensed by/i)).toBeInTheDocument(); + + // Verify fulfilled lab order shows phlebotomist/pharmacist attribution + expect(screen.getByText('Fasting Lipid Panel & HbA1c')).toBeInTheDocument(); + expect(screen.getByText('Marcus Vance, PharmD')).toBeInTheDocument(); + expect(screen.getByText(/Sample collected by/i)).toBeInTheDocument(); + + // Verify pending item shows action buttons + expect(screen.getByText('Metformin 850mg (60 tablets)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /dispense & fulfill/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument(); + }); + + it('triggers fulfillment action when staff dispenses pending item', async () => { + const onAction = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + const dispenseBtn = screen.getByRole('button', { name: /dispense & fulfill/i }); + await userEvent.click(dispenseBtn); + + expect(onAction).toHaveBeenCalledWith('queue-3', 'fulfilled'); + }); + }); +}); diff --git a/src/features/facility-team/components/FacilityTeamView.tsx b/src/features/facility-team/components/FacilityTeamView.tsx new file mode 100644 index 0000000..6a8d22b --- /dev/null +++ b/src/features/facility-team/components/FacilityTeamView.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { Button } from '../../../components/Button'; +import { Spinner } from '../../../components/Spinner'; +import { FacilityMemberList } from './FacilityMemberList'; +import { PendingInvitesList } from './PendingInvitesList'; +import { InviteMemberModal } from './InviteMemberModal'; +import { + useFacilityMembers, + useFacilityInvites, + useInviteMember, + useResendInvite, + useRevokeInvite, + useRemoveMember, +} from '../hooks/useFacilityTeam'; +import { useAuthStore } from '../../../store/auth-store'; +import type { FacilityMember, FacilityInvite, InviteMemberInput } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +// Fallback initial team data for preview/development when backend endpoint is not yet connected +export const DEFAULT_MOCK_MEMBERS: FacilityMember[] = [ + { + id: 'mem-1', + facilityId: 'fac-main', + userId: 'user-owner-1', + fullName: 'Dr. Evelyn Reed', + email: 'evelyn.reed@centralpharmacy.org', + role: 'owner', + joinedAt: '2026-08-15T09:00:00Z', + isCurrentUser: true, + }, + { + id: 'mem-2', + facilityId: 'fac-main', + userId: 'user-staff-1', + fullName: 'Marcus Vance, PharmD', + email: 'marcus.vance@centralpharmacy.org', + role: 'staff', + joinedAt: '2026-09-01T14:30:00Z', + isCurrentUser: false, + }, + { + id: 'mem-3', + facilityId: 'fac-main', + userId: 'user-staff-2', + fullName: 'Aisha Patel', + email: 'aisha.patel@centralpharmacy.org', + role: 'staff', + joinedAt: '2026-09-20T11:15:00Z', + isCurrentUser: false, + }, +]; + +export const DEFAULT_MOCK_INVITES: FacilityInvite[] = [ + { + id: 'inv-1', + facilityId: 'fac-main', + email: 'david.kim@centralpharmacy.org', + role: 'staff', + invitedBy: 'evelyn.reed@centralpharmacy.org', + invitedAt: '2026-10-15T16:00:00Z', + expiresAt: '2026-10-22T16:00:00Z', + status: 'pending', + }, +]; + +interface FacilityTeamViewProps { + facilityId?: string; + isOwnerOverride?: boolean; +} + +export function FacilityTeamView({ + facilityId = 'fac-main', + isOwnerOverride, +}: FacilityTeamViewProps) { + const user = useAuthStore((state) => state.user); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + + const { data: membersData, isLoading: membersLoading } = useFacilityMembers(facilityId); + + // Resolved members: live data or mock preview fallback + const members: FacilityMember[] = membersData ?? DEFAULT_MOCK_MEMBERS; + + // Determine if current user is an owner + // If isOwnerOverride is provided (for testing/staff view demo), use that; + // otherwise check if user matches an owner member or has owner role + const isOwner = + isOwnerOverride !== undefined + ? isOwnerOverride + : Boolean( + members.find((m) => m.email === user?.email)?.role === 'owner' || + members.find((m) => m.isCurrentUser)?.role === 'owner' + ); + + const { data: invitesData } = useFacilityInvites(facilityId, isOwner); + const invites: FacilityInvite[] = invitesData ?? (isOwner ? DEFAULT_MOCK_INVITES : []); + + const inviteMutation = useInviteMember(facilityId); + const resendMutation = useResendInvite(facilityId); + const revokeMutation = useRevokeInvite(facilityId); + const removeMutation = useRemoveMember(facilityId); + + if (membersLoading) { + return ( +
+
+ +
+
+ ); + } + + const handleInviteSubmit = async (input: InviteMemberInput) => { + if (!isOwner) { + throw new Error('Unauthorized: Only facility owners can send staff invitations.'); + } + await inviteMutation.mutateAsync(input); + }; + + const handleRemoveMember = async (memberId: string) => { + if (!isOwner) { + throw new Error('Unauthorized: Only facility owners can remove members.'); + } + await removeMutation.mutateAsync(memberId); + }; + + const handleResendInvite = async (inviteId: string) => { + if (!isOwner) { + throw new Error('Unauthorized: Only facility owners can resend invitations.'); + } + await resendMutation.mutateAsync(inviteId); + }; + + const handleRevokeInvite = async (inviteId: string) => { + if (!isOwner) { + throw new Error('Unauthorized: Only facility owners can revoke invitations.'); + } + await revokeMutation.mutateAsync(inviteId); + }; + + return ( +
+
+
+

Facility team & accounts

+

+ Manage staff members, roles, and invitation access for this facility. +

+
+ {isOwner && ( + + )} +
+ + {/* Role Notice Banner */} + {isOwner ? ( +
+ + + Facility Owner Access: You have administrative permissions to invite new members, assign roles, and revoke account access. + +
+ ) : ( +
+ + + Staff Member View (Read-only): You have dispensing and fulfillment access. Only facility owners can invite or remove team members. + +
+ )} + + {/* Members List */} + + + {/* Pending Invites (Owners only) */} + {isOwner && ( + + )} + + {/* Invite Member Modal */} + setInviteModalOpen(false)} + onInvite={handleInviteSubmit} + isPending={inviteMutation.isPending} + isOwner={isOwner} + /> +
+ ); +} diff --git a/src/features/facility-team/components/FulfillmentQueueWithAttribution.tsx b/src/features/facility-team/components/FulfillmentQueueWithAttribution.tsx new file mode 100644 index 0000000..cc81221 --- /dev/null +++ b/src/features/facility-team/components/FulfillmentQueueWithAttribution.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { Button } from '../../../components/Button'; +import type { FulfillmentQueueItem } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +interface FulfillmentQueueProps { + items: FulfillmentQueueItem[]; + onAction: (itemId: string, action: 'fulfilled' | 'rejected', notes?: string) => Promise; + isUpdating: boolean; +} + +export function FulfillmentQueueWithAttribution({ + items, + onAction, + isUpdating, +}: FulfillmentQueueProps) { + const [selectedItem, setSelectedItem] = useState(null); + + const handleAction = async (itemId: string, action: 'fulfilled' | 'rejected') => { + setSelectedItem(itemId); + try { + await onAction(itemId, action); + } finally { + setSelectedItem(null); + } + }; + + return ( +
+
+
+

Fulfillment queue & action attribution

+

+ Prescriptions and diagnostic tests assigned to this facility with audit trail attribution. +

+
+
+ + {items.length === 0 ? ( +
No items in fulfillment queue
+ ) : ( +
+ {items.map((item) => { + const isItemUpdating = isUpdating && selectedItem === item.id; + + return ( +
+
+
+ {item.itemName} +
+ Patient: {item.patientName} •{' '} + Type: {item.orderType === 'prescription' ? 'Prescription Dispense' : 'Diagnostic Order'} •{' '} + Received: {new Date(item.receivedAt).toLocaleDateString()} +
+
+
+ + {item.status === 'fulfilled' ? 'Fulfilled / Dispensed' : item.status} + +
+
+ + {item.instructions && ( +

+ Instructions: {item.instructions} +

+ )} + + {/* Audit Attribution Box (PRD §7 & Issue #51 requirement) */} + {item.attribution ? ( +
+ + + {item.attribution.action === 'dispensed' && 'Dispensed by '} + {item.attribution.action === 'sample_collected' && 'Sample collected by '} + {item.attribution.action === 'verified' && 'Verified by '} + {item.attribution.action === 'rejected' && 'Rejected by '} + {item.attribution.performedByName}{' '} + ({item.attribution.performedByRole}) on{' '} + {new Date(item.attribution.timestamp).toLocaleString()} + {item.attribution.notes && ` — "${item.attribution.notes}"`} + +
+ ) : ( +
+ + Pending staff action. Fulfilling or rejecting will record your staff credentials for statutory attribution. + +
+ + +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/features/facility-team/components/InviteMemberModal.tsx b/src/features/facility-team/components/InviteMemberModal.tsx new file mode 100644 index 0000000..f6bdf72 --- /dev/null +++ b/src/features/facility-team/components/InviteMemberModal.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { Button } from '../../../components/Button'; +import { TextField } from '../../../components/TextField'; +import { FormError } from '../../../components/FormError'; +import type { FacilityMemberRole, InviteMemberInput } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +interface InviteMemberModalProps { + open: boolean; + onClose: () => void; + onInvite: (input: InviteMemberInput) => Promise; + isPending: boolean; + isOwner: boolean; +} + +export function InviteMemberModal({ + open, + onClose, + onInvite, + isPending, + isOwner, +}: InviteMemberModalProps) { + const [email, setEmail] = useState(''); + const [role, setRole] = useState('staff'); + const [error, setError] = useState(null); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Guard: Owner-only action check + if (!isOwner) { + setError('Unauthorized: Only facility owners can invite team members.'); + return; + } + + if (!email || !email.includes('@')) { + setError('Please provide a valid email address.'); + return; + } + + try { + await onInvite({ email, role }); + setEmail(''); + setRole('staff'); + onClose(); + } catch (err) { + setError((err as Error).message || 'Failed to send invitation'); + } + }; + + return ( +
+
+

Invite team member

+

+ Send an invitation email to a staff pharmacist or co-owner. They will receive a link to activate their account and join this facility. +

+ +
+ setEmail(e.target.value)} + placeholder="colleague@pharmacy.org" + required + autoFocus + /> + +
+ + +
+ + {error && } + +
+ + +
+ +
+
+ ); +} diff --git a/src/features/facility-team/components/PendingInvitesList.tsx b/src/features/facility-team/components/PendingInvitesList.tsx new file mode 100644 index 0000000..9d9bd41 --- /dev/null +++ b/src/features/facility-team/components/PendingInvitesList.tsx @@ -0,0 +1,97 @@ +import { Button } from '../../../components/Button'; +import type { FacilityInvite } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +interface PendingInvitesListProps { + invites: FacilityInvite[]; + isOwner: boolean; + onResend: (inviteId: string) => Promise; + onRevoke: (inviteId: string) => Promise; + isResending: boolean; + isRevoking: boolean; +} + +export function PendingInvitesList({ + invites, + isOwner, + onResend, + onRevoke, + isResending, + isRevoking, +}: PendingInvitesListProps) { + // If not owner, do not render pending invites (hidden for staff) + if (!isOwner) return null; + + return ( +
+
+
+

Pending invitations

+

+ Outstanding invitations awaiting acceptance by invited staff. +

+
+
+ + {invites.length === 0 ? ( +
No pending invitations
+ ) : ( +
+ + + + + + + + + + + + + {invites.map((invite) => ( + + + + + + + + + ))} + +
Invited emailRoleInvited bySentExpiresActions
{invite.email} + + {invite.role} + + {invite.invitedBy}{new Date(invite.invitedAt).toLocaleDateString()}{new Date(invite.expiresAt).toLocaleDateString()} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/features/facility-team/components/RemoveMemberModal.tsx b/src/features/facility-team/components/RemoveMemberModal.tsx new file mode 100644 index 0000000..b7c9e08 --- /dev/null +++ b/src/features/facility-team/components/RemoveMemberModal.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { Button } from '../../../components/Button'; +import { FormError } from '../../../components/FormError'; +import type { FacilityMember } from '../../../types/models'; +import styles from './FacilityTeam.module.css'; + +interface RemoveMemberModalProps { + member: FacilityMember | null; + onClose: () => void; + onConfirm: (memberId: string) => Promise; + isPending: boolean; + isLastOwner: boolean; + isOwner: boolean; +} + +export function RemoveMemberModal({ + member, + onClose, + onConfirm, + isPending, + isLastOwner, + isOwner, +}: RemoveMemberModalProps) { + const [error, setError] = useState(null); + + if (!member) return null; + + const handleConfirm = async () => { + setError(null); + + // Guard: Owner-only action + if (!isOwner) { + setError('Unauthorized: Only facility owners can remove members.'); + return; + } + + // Guard: Cannot remove last owner + if (member.role === 'owner' && isLastOwner) { + setError('Cannot remove the only facility owner. Transfer ownership or invite another owner before removing this account.'); + return; + } + + try { + await onConfirm(member.id); + onClose(); + } catch (err) { + setError((err as Error).message || 'Failed to remove member'); + } + }; + + return ( +
+
+

Remove team member

+

+ Are you sure you want to remove {member.fullName} ({member.email}) from this facility? +

+ +
+ Important security notice: Removing a member immediately signs them out and revokes all active backend sessions and dispensing permissions for this facility. +
+ + {isLastOwner && member.role === 'owner' && ( +
+ ⚠️ This user is the only registered owner for this facility. A facility must retain at least one active owner. +
+ )} + + {error && } + +
+ + +
+
+
+ ); +} diff --git a/src/features/facility-team/hooks/useFacilityTeam.ts b/src/features/facility-team/hooks/useFacilityTeam.ts new file mode 100644 index 0000000..1806875 --- /dev/null +++ b/src/features/facility-team/hooks/useFacilityTeam.ts @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import * as teamService from '../../../services/facility-team-service'; +import { useAuthStore } from '../../../store/auth-store'; +import type { InviteMemberInput } from '../../../types/models'; + +export function useFacilityMembers(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['facility', facilityId, 'members'], + queryFn: () => teamService.listFacilityMembers(facilityId, accessToken!), + enabled: Boolean(accessToken && facilityId), + }); +} + +export function useFacilityInvites(facilityId: string, isOwner: boolean) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['facility', facilityId, 'invites'], + queryFn: () => teamService.listFacilityInvites(facilityId, accessToken!), + enabled: Boolean(accessToken && facilityId && isOwner), + }); +} + +export function useInviteMember(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: InviteMemberInput) => + teamService.inviteFacilityMember(facilityId, input, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['facility', facilityId, 'invites'] }); + }, + }); +} + +export function useResendInvite(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (inviteId: string) => + teamService.resendFacilityInvite(facilityId, inviteId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['facility', facilityId, 'invites'] }); + }, + }); +} + +export function useRevokeInvite(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (inviteId: string) => + teamService.revokeFacilityInvite(facilityId, inviteId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['facility', facilityId, 'invites'] }); + }, + }); +} + +export function useRemoveMember(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (memberId: string) => + teamService.removeFacilityMember(facilityId, memberId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['facility', facilityId, 'members'] }); + }, + }); +} + +export function useFulfillmentQueue(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['facility', facilityId, 'queue'], + queryFn: () => teamService.getFulfillmentQueue(facilityId, accessToken!), + enabled: Boolean(accessToken && facilityId), + }); +} + +export function useUpdateFulfillmentItem(facilityId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ itemId, action, notes }: { itemId: string; action: 'fulfilled' | 'rejected'; notes?: string }) => + teamService.updateFulfillmentItem(facilityId, itemId, action, accessToken!, notes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['facility', facilityId, 'queue'] }); + }, + }); +} diff --git a/src/features/insights/components/DateRangeSelector.tsx b/src/features/insights/components/DateRangeSelector.tsx new file mode 100644 index 0000000..1dce079 --- /dev/null +++ b/src/features/insights/components/DateRangeSelector.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import type { DateRangePreset } from '../../../types/models'; +import styles from './PracticeInsights.module.css'; + +interface DateRangeSelectorProps { + currentPreset: DateRangePreset; + startDate?: string; + endDate?: string; + onChangePreset: (preset: DateRangePreset, customRange?: { startDate: string; endDate: string }) => void; +} + +export function DateRangeSelector({ + currentPreset, + startDate = '', + endDate = '', + onChangePreset, +}: DateRangeSelectorProps) { + const [customStart, setCustomStart] = useState(startDate); + const [customEnd, setCustomEnd] = useState(endDate); + + const presets: { id: DateRangePreset; label: string }[] = [ + { id: '7d', label: 'Last 7 days' }, + { id: '30d', label: 'Last 30 days' }, + { id: '90d', label: 'Last 90 days' }, + { id: 'custom', label: 'Custom range' }, + ]; + + const handleApplyCustom = (e: React.FormEvent) => { + e.preventDefault(); + if (customStart && customEnd) { + onChangePreset('custom', { startDate: customStart, endDate: customEnd }); + } + }; + + return ( +
+
+ {presets.map((p) => ( + + ))} +
+ + {currentPreset === 'custom' && ( +
+ + setCustomStart(e.target.value)} + required + aria-label="Start date" + /> + + + setCustomEnd(e.target.value)} + required + aria-label="End date" + /> + +
+ )} +
+ ); +} diff --git a/src/features/insights/components/NoShowMetricsCard.tsx b/src/features/insights/components/NoShowMetricsCard.tsx new file mode 100644 index 0000000..7f80571 --- /dev/null +++ b/src/features/insights/components/NoShowMetricsCard.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import type { NoShowMetrics } from '../../../types/models'; +import styles from './PracticeInsights.module.css'; + +interface NoShowMetricsCardProps { + metrics: NoShowMetrics; +} + +export function NoShowMetricsCard({ metrics }: NoShowMetricsCardProps) { + const [viewMode, setViewMode] = useState<'visual' | 'table'>('visual'); + + const total = Math.max(1, metrics.totalScheduled); + const completedPct = (metrics.completedVisits / total) * 100; + const noShowPct = (metrics.noShows / total) * 100; + const lateCancelPct = (metrics.lateCancellations / total) * 100; + const onTimeCancelPct = (metrics.onTimeCancellations / total) * 100; + + return ( +
+
+
+

No-show and cancellation rates

+

+ Patient attendance patterns and late cancellation turnaround. +

+
+
+ + +
+
+ + {metrics.sampleSizeWarning && ( +
+ Small sample caveat + + Only {metrics.totalScheduled} total scheduled visits in this period. No-show and late-cancellation percentages may be disproportionately influenced by single events. + +
+ )} + +
+
+ No-show rate + 10 ? '#b3261e' : 'inherit' }}> + {metrics.noShowRate.toFixed(1)}% + + {metrics.noShows} unattended visits +
+
+ Late cancel rate + 10 ? '#b87b28' : 'inherit' }}> + {metrics.lateCancelRate.toFixed(1)}% + + {metrics.lateCancellations} cancelled within 24h +
+
+ Completed visits + {metrics.completedVisits} + {completedPct.toFixed(0)}% completion +
+
+ + {viewMode === 'visual' ? ( +
+
+
+
+
+
+
+ +
+
+ + Completed ({metrics.completedVisits}) +
+
+ + No-shows ({metrics.noShows}) +
+
+ + Late cancels (<24h) ({metrics.lateCancellations}) +
+
+ + On-time cancels ({metrics.onTimeCancellations}) +
+
+
+ ) : null} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Detailed count and proportion breakdown for appointments in selected period
Status / OutcomeCountProportion of ScheduledNotes
Completed Visits{metrics.completedVisits}{completedPct.toFixed(1)}%Patient attended and visit was concluded
No-Shows{metrics.noShows}{metrics.noShowRate.toFixed(1)}%Patient did not check in or attend without notice
Late Cancellations{metrics.lateCancellations}{metrics.lateCancelRate.toFixed(1)}%Cancelled less than 24 hours prior to appointment
On-Time Cancellations{metrics.onTimeCancellations}{onTimeCancelPct.toFixed(1)}%Cancelled with >= 24 hours advance notice
Total Scheduled{metrics.totalScheduled}100.0%All bookings in selected date range
+
+
+ ); +} diff --git a/src/features/insights/components/PracticeInsights.module.css b/src/features/insights/components/PracticeInsights.module.css new file mode 100644 index 0000000..2693e2b --- /dev/null +++ b/src/features/insights/components/PracticeInsights.module.css @@ -0,0 +1,335 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-6); + width: 100%; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.titleRow { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--space-4); +} + +.titleRow h1 { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.privacyBanner { + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.caveatBanner { + background: #fff8e6; + color: #7a5000; + border: 1px solid #e6c875; + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-size: var(--text-sm); + display: flex; + align-items: flex-start; + gap: var(--space-3); + line-height: var(--leading-normal); +} + +.caveatPill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: #fdf3d8; + color: #8c5b00; + border: 1px solid #ebd08c; + border-radius: var(--radius-full); + padding: 0.15rem 0.5rem; + font-size: var(--text-xs); + font-weight: 500; +} + +/* Date Range Selector */ +.rangeSelector { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.presetGroup { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.presetButton { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-family: var(--font-sans); + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); + cursor: pointer; + transition: all 0.15s ease; +} + +.presetButton:hover { + background: var(--color-surface-hover); +} + +.presetButtonActive { + background: var(--color-accent); + color: #ffffff; + border-color: var(--color-accent); +} + +.customInputs { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.dateInput { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-family: var(--font-sans); + font-size: var(--text-sm); + background: var(--color-bg); + color: var(--color-text); +} + +/* Card Sections */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + box-shadow: var(--shadow-sm); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +.cardHeader h2 { + margin: 0; + font-size: var(--text-xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.cardSubtitle { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.viewToggle { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.toggleButton { + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); + font-family: var(--font-sans); + font-weight: 500; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.toggleButtonActive { + background: var(--color-accent); + color: #ffffff; +} + +/* Metrics Summary Grid */ +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-4); +} + +.summaryStat { + background: var(--color-surface-alt); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.statLabel { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.statValue { + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--color-text); + font-weight: 600; +} + +.statHint { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +/* Chart Canvas */ +.chartContainer { + width: 100%; + overflow-x: auto; + padding: var(--space-2) 0; +} + +.svgChart { + width: 100%; + height: 240px; + display: block; +} + +.chartLegend { + display: flex; + gap: var(--space-4); + font-size: var(--text-xs); + color: var(--color-text-muted); + align-items: center; + justify-content: flex-end; + margin-top: var(--space-2); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.legendColor { + width: 12px; + height: 12px; + border-radius: 2px; +} + +/* Progress / Distribution Bar */ +.distributionBar { + display: flex; + height: 16px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--color-surface-alt); + margin: var(--space-2) 0; +} + +.distributionSegment { + height: 100%; + transition: width 0.3s ease; +} + +/* Accessible Table */ +.tableWrapper { + width: 100%; + overflow-x: auto; +} + +.dataTable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); + text-align: left; +} + +.dataTable caption { + text-align: left; + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-bottom: var(--space-2); +} + +.dataTable th, +.dataTable td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.dataTable th { + background: var(--color-surface-alt); + color: var(--color-text); + font-weight: 600; +} + +.dataTable td.num, +.dataTable th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.dataTable tr:hover td { + background: var(--color-surface-hover); +} + +.gridTwoCols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--space-6); +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .presetButton, + .toggleButton, + .distributionSegment { + transition: none !important; + } +} diff --git a/src/features/insights/components/PracticeInsights.test.tsx b/src/features/insights/components/PracticeInsights.test.tsx new file mode 100644 index 0000000..0148b77 --- /dev/null +++ b/src/features/insights/components/PracticeInsights.test.tsx @@ -0,0 +1,214 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test/render'; +import { PracticeInsightsView, DEFAULT_MOCK_INSIGHTS } from './PracticeInsightsView'; +import { UtilisationChart } from './UtilisationChart'; +import { NoShowMetricsCard } from './NoShowMetricsCard'; +import { TurnaroundMetricsCard } from './TurnaroundMetricsCard'; +import { DateRangeSelector } from './DateRangeSelector'; +import { useAuthStore } from '../../../store/auth-store'; +import * as insightsService from '../../../services/insights-service'; +import type { DoctorPracticeInsights } from '../../../types/models'; + +vi.mock('../../../services/insights-service'); + +const MOCK_LOW_SAMPLE_INSIGHTS: DoctorPracticeInsights = { + ...DEFAULT_MOCK_INSIGHTS, + utilisation: { + weeks: [ + { + weekLabel: 'Oct 12', + startDate: '2026-10-12', + endDate: '2026-10-18', + offeredSlots: 4, + bookedSlots: 2, + completedSlots: 2, + utilisationRate: 50.0, + }, + ], + overallOfferedSlots: 4, + overallBookedSlots: 2, + overallUtilisationRate: 50.0, + sampleSizeWarning: true, + }, + noShows: { + totalScheduled: 3, + completedVisits: 2, + noShows: 1, + lateCancellations: 0, + onTimeCancellations: 0, + noShowRate: 33.3, + lateCancelRate: 0.0, + sampleSizeWarning: true, + }, + referrals: { + totalSent: 2, + totalAccepted: 1, + totalPending: 1, + averageAcceptanceHours: 12.0, + medianAcceptanceHours: 12.0, + breakdownUnder24h: 1, + breakdown24to48h: 0, + breakdownOver48h: 0, + sampleSizeWarning: true, + }, + messages: { + totalThreads: 3, + averageFirstResponseMinutes: 15, + medianFirstResponseMinutes: 12, + respondedWithin2hCount: 3, + respondedWithin24hCount: 3, + rateWithin2h: 100.0, + sampleSizeWarning: true, + }, +}; + +beforeEach(() => { + useAuthStore.setState({ + accessToken: 'test-token', + user: { id: 'doc-1', email: 'doctor@docforum.org', role: 'doctor', createdAt: '', updatedAt: '' }, + }); + vi.mocked(insightsService.getDoctorPracticeInsights).mockResolvedValue(DEFAULT_MOCK_INSIGHTS); +}); + +describe('Doctor Practice Insights (#50)', () => { + it('renders privacy guarantee ensuring strictly doctor own data and no peer comparisons', async () => { + render(); + + await screen.findByRole('heading', { name: /practice insights/i }); + expect( + screen.getByText(/This view is strictly private to your account. No comparisons are made with named peer doctors./i), + ).toBeInTheDocument(); + }); + + describe('UtilisationChart', () => { + it('renders weekly utilisation with both chart view and accessible table alternative', async () => { + render( + , + ); + + // Verify stats summary + expect(screen.getByText('125')).toBeInTheDocument(); + expect(screen.getByText('108')).toBeInTheDocument(); + expect(screen.getByText('86.4%')).toBeInTheDocument(); + + // Check chart exists + expect(screen.getByRole('img', { name: /Bar chart showing weekly offered vs booked slots/i })).toBeInTheDocument(); + + // Switch to table view + const tableButton = screen.getByRole('button', { name: /table view/i }); + await userEvent.click(tableButton); + + // Verify table alternative has appropriate headers and contents + const table = screen.getByRole('table', { name: /weekly slot utilisation/i }); + expect(table).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Offered/i })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Booked/i })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Utilisation/i })).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: 'Sep 21' })).toBeInTheDocument(); + }); + + it('shows small-sample caveat when slot count is low', () => { + render( + , + ); + + expect(screen.getByText(/Only 4 slots offered in this period/i)).toBeInTheDocument(); + expect(screen.getByText(/Small sample caveat/i)).toBeInTheDocument(); + }); + }); + + describe('NoShowMetricsCard', () => { + it('renders no-show rates and accessible table alternative', async () => { + render(); + + expect(screen.getAllByText('4.6%').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('2.8%').length).toBeGreaterThanOrEqual(1); + + // Switch to table view + const tableButton = screen.getByRole('button', { name: /table view/i }); + await userEvent.click(tableButton); + + const table = screen.getByRole('table', { name: /Appointment attendance and cancellation metrics table/i }); + expect(table).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: /No-Shows/i })).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: /Late Cancellations/i })).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: /Completed Visits/i })).toBeInTheDocument(); + }); + + it('displays small-sample caveat for low appointment volume', () => { + render(); + + expect(screen.getByText(/Only 3 total scheduled visits in this period/i)).toBeInTheDocument(); + }); + }); + + describe('TurnaroundMetricsCard', () => { + it('renders referral and message turnaround with accessible table alternatives', async () => { + render( + , + ); + + expect(screen.getByText(/Referral acceptance turnaround/i)).toBeInTheDocument(); + expect(screen.getByText(/Messages response time/i)).toBeInTheDocument(); + expect(screen.getByText('14.0h')).toBeInTheDocument(); + expect(screen.getByText('24m')).toBeInTheDocument(); + + // Switch both sections to table view + const tableButtons = screen.getAllByRole('button', { name: /table view/i }); + for (const btn of tableButtons) { + await userEvent.click(btn); + } + + const refTable = screen.getByRole('table', { name: /Referral turnaround breakdown table/i }); + expect(refTable).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: /Under 24 hours/i })).toBeInTheDocument(); + + const msgTable = screen.getByRole('table', { name: /Patient message response time table/i }); + expect(msgTable).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: /Within 2 hours/i })).toBeInTheDocument(); + }); + + it('displays small-sample caveats on turnaround when sample is low', () => { + render( + , + ); + + expect(screen.getByText(/Only 2 referrals issued in this period/i)).toBeInTheDocument(); + expect(screen.getByText(/Only 3 message threads in this period/i)).toBeInTheDocument(); + }); + }); + + describe('DateRangeSelector', () => { + it('allows changing presets and applying custom range', async () => { + const onChange = vi.fn(); + render(); + + const sevenDays = screen.getByRole('button', { name: /Last 7 days/i }); + await userEvent.click(sevenDays); + expect(onChange).toHaveBeenCalledWith('7d'); + + const customBtn = screen.getByRole('button', { name: /Custom range/i }); + await userEvent.click(customBtn); + expect(onChange).toHaveBeenCalledWith('custom'); + }); + }); +}); diff --git a/src/features/insights/components/PracticeInsightsView.tsx b/src/features/insights/components/PracticeInsightsView.tsx new file mode 100644 index 0000000..8420336 --- /dev/null +++ b/src/features/insights/components/PracticeInsightsView.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react'; +import { useDoctorInsights } from '../hooks/useDoctorInsights'; +import { DateRangeSelector } from './DateRangeSelector'; +import { UtilisationChart } from './UtilisationChart'; +import { NoShowMetricsCard } from './NoShowMetricsCard'; +import { TurnaroundMetricsCard } from './TurnaroundMetricsCard'; +import { Spinner } from '../../../components/Spinner'; +import type { DateRangePreset, DoctorPracticeInsights } from '../../../types/models'; +import styles from './PracticeInsights.module.css'; + +// Fallback initial data when backend is not yet populated +export const DEFAULT_MOCK_INSIGHTS: DoctorPracticeInsights = { + doctorProfileId: 'doc-me', + range: { + preset: '30d', + startDate: '2026-09-18', + endDate: '2026-10-18', + }, + generatedAt: new Date().toISOString(), + utilisation: { + weeks: [ + { + weekLabel: 'Sep 21', + startDate: '2026-09-21', + endDate: '2026-09-27', + offeredSlots: 30, + bookedSlots: 26, + completedSlots: 24, + utilisationRate: 86.7, + }, + { + weekLabel: 'Sep 28', + startDate: '2026-09-28', + endDate: '2026-10-04', + offeredSlots: 32, + bookedSlots: 29, + completedSlots: 27, + utilisationRate: 90.6, + }, + { + weekLabel: 'Oct 05', + startDate: '2026-10-05', + endDate: '2026-10-11', + offeredSlots: 28, + bookedSlots: 22, + completedSlots: 20, + utilisationRate: 78.6, + }, + { + weekLabel: 'Oct 12', + startDate: '2026-10-12', + endDate: '2026-10-18', + offeredSlots: 35, + bookedSlots: 31, + completedSlots: 29, + utilisationRate: 88.6, + }, + ], + overallOfferedSlots: 125, + overallBookedSlots: 108, + overallUtilisationRate: 86.4, + sampleSizeWarning: false, + }, + noShows: { + totalScheduled: 108, + completedVisits: 100, + noShows: 5, + lateCancellations: 3, + onTimeCancellations: 6, + noShowRate: 4.6, + lateCancelRate: 2.8, + sampleSizeWarning: false, + }, + referrals: { + totalSent: 24, + totalAccepted: 21, + totalPending: 3, + averageAcceptanceHours: 19.4, + medianAcceptanceHours: 14.0, + breakdownUnder24h: 15, + breakdown24to48h: 5, + breakdownOver48h: 1, + sampleSizeWarning: false, + }, + messages: { + totalThreads: 42, + averageFirstResponseMinutes: 38, + medianFirstResponseMinutes: 24, + respondedWithin2hCount: 36, + respondedWithin24hCount: 41, + rateWithin2h: 85.7, + sampleSizeWarning: false, + }, +}; + +export function PracticeInsightsView() { + const [preset, setPreset] = useState('30d'); + const [customRange, setCustomRange] = useState<{ startDate: string; endDate: string } | undefined>(); + + const { data, isLoading, isError } = useDoctorInsights({ + preset, + startDate: customRange?.startDate, + endDate: customRange?.endDate, + }); + + const handleRangeChange = (newPreset: DateRangePreset, range?: { startDate: string; endDate: string }) => { + setPreset(newPreset); + if (range) { + setCustomRange(range); + } else { + setCustomRange(undefined); + } + }; + + // If query is loading and we don't have cached data yet, show spinner + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + // Gracefully fallback to DEFAULT_MOCK_INSIGHTS when backend endpoint is not yet connected + const insights: DoctorPracticeInsights = data ?? DEFAULT_MOCK_INSIGHTS; + + return ( +
+
+
+
+

Practice insights

+

+ Private performance and operational metrics for your clinical practice. +

+
+
+ +
+ + + Doctor privacy guarantee: This view is strictly private to your account. No comparisons are made with named peer doctors. + +
+ + +
+ + {isError && ( +
+ Notice + + Live metrics API returned an error or is waking up; showing practice analytics preview data. + +
+ )} + + + + + + +
+ ); +} diff --git a/src/features/insights/components/TurnaroundMetricsCard.tsx b/src/features/insights/components/TurnaroundMetricsCard.tsx new file mode 100644 index 0000000..ad4db9c --- /dev/null +++ b/src/features/insights/components/TurnaroundMetricsCard.tsx @@ -0,0 +1,292 @@ +import { useState } from 'react'; +import type { ReferralTurnaroundMetrics, MessageResponseMetrics } from '../../../types/models'; +import styles from './PracticeInsights.module.css'; + +interface TurnaroundMetricsCardProps { + referrals: ReferralTurnaroundMetrics; + messages: MessageResponseMetrics; +} + +export function TurnaroundMetricsCard({ referrals, messages }: TurnaroundMetricsCardProps) { + const [refViewMode, setRefViewMode] = useState<'chart' | 'table'>('chart'); + const [msgViewMode, setMsgViewMode] = useState<'chart' | 'table'>('chart'); + + const refTotal = Math.max(1, referrals.totalAccepted); + const refUnder24Pct = (referrals.breakdownUnder24h / refTotal) * 100; + const ref24to48Pct = (referrals.breakdown24to48h / refTotal) * 100; + const refOver48Pct = (referrals.breakdownOver48h / refTotal) * 100; + + const msgTotal = Math.max(1, messages.totalThreads); + const msgWithin2hPct = (messages.respondedWithin2hCount / msgTotal) * 100; + const msg2to24hPct = ((messages.respondedWithin24hCount - messages.respondedWithin2hCount) / msgTotal) * 100; + const msgOver24hPct = Math.max(0, 100 - msgWithin2hPct - msg2to24hPct); + + return ( +
+ {/* Referral Turnaround Section */} +
+
+
+

Referral acceptance turnaround

+

+ Time from when you issue a referral to when the receiving specialist/facility accepts it. +

+
+
+ + +
+
+ + {referrals.sampleSizeWarning && ( +
+ Small sample caveat + + Only {referrals.totalSent} referrals issued in this period. Turnaround averages may be skewed by anomalous delays. + +
+ )} + +
+
+ Median acceptance + + {referrals.medianAcceptanceHours < 24 + ? `${referrals.medianAcceptanceHours.toFixed(1)}h` + : `${(referrals.medianAcceptanceHours / 24).toFixed(1)}d`} + + 50% accepted faster than this +
+
+ Average turnaround + + {referrals.averageAcceptanceHours < 24 + ? `${referrals.averageAcceptanceHours.toFixed(1)}h` + : `${(referrals.averageAcceptanceHours / 24).toFixed(1)}d`} + + {referrals.totalAccepted} of {referrals.totalSent} accepted +
+
+ + {refViewMode === 'chart' ? ( +
+ Turnaround distribution (accepted referrals) +
+
+
+
+
+
+
+ + <24h ({referrals.breakdownUnder24h}) +
+
+ + 24–48h ({referrals.breakdown24to48h}) +
+
+ + >48h ({referrals.breakdownOver48h}) +
+
+
+ ) : null} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Referral acceptance time breakdown for period
Turnaround bucketCountPercentage
Under 24 hours{referrals.breakdownUnder24h}{refUnder24Pct.toFixed(1)}%
24 to 48 hours{referrals.breakdown24to48h}{ref24to48Pct.toFixed(1)}%
Over 48 hours{referrals.breakdownOver48h}{refOver48Pct.toFixed(1)}%
Total Accepted{referrals.totalAccepted}100.0%
Pending Acceptance{referrals.totalPending}—
+
+
+ + {/* Messages Response Time Section */} +
+
+
+

Messages response time

+

+ Speed of your initial reply to patient inquiry threads. +

+
+
+ + +
+
+ + {messages.sampleSizeWarning && ( +
+ Small sample caveat + + Only {messages.totalThreads} message threads in this period. Response averages may reflect single isolated conversations. + +
+ )} + +
+
+ Median response + + {messages.medianFirstResponseMinutes < 60 + ? `${messages.medianFirstResponseMinutes}m` + : `${(messages.medianFirstResponseMinutes / 60).toFixed(1)}h`} + + 50% replied faster +
+
+ Within 2 hours + {messages.rateWithin2h.toFixed(0)}% + {messages.respondedWithin2hCount} of {messages.totalThreads} threads +
+
+ + {msgViewMode === 'chart' ? ( +
+ Initial reply speed distribution +
+
+
+
+
+
+
+ + <2h ({messages.respondedWithin2hCount}) +
+
+ + 2–24h ({Math.max(0, messages.respondedWithin24hCount - messages.respondedWithin2hCount)}) +
+
+ + >24h ({Math.max(0, msgTotal - messages.respondedWithin24hCount)}) +
+
+
+ ) : null} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Breakdown of time to initial response for incoming message threads
Response windowCountPercentage
Within 2 hours{messages.respondedWithin2hCount}{msgWithin2hPct.toFixed(1)}%
2 to 24 hours{Math.max(0, messages.respondedWithin24hCount - messages.respondedWithin2hCount)}{msg2to24hPct.toFixed(1)}%
Over 24 hours{Math.max(0, msgTotal - messages.respondedWithin24hCount)}{msgOver24hPct.toFixed(1)}%
Total Threads{messages.totalThreads}100.0%
+
+
+
+ ); +} diff --git a/src/features/insights/components/UtilisationChart.tsx b/src/features/insights/components/UtilisationChart.tsx new file mode 100644 index 0000000..c575364 --- /dev/null +++ b/src/features/insights/components/UtilisationChart.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import type { UtilisationWeek } from '../../../types/models'; +import styles from './PracticeInsights.module.css'; + +interface UtilisationChartProps { + weeks: UtilisationWeek[]; + overallOfferedSlots: number; + overallBookedSlots: number; + overallUtilisationRate: number; + sampleSizeWarning: boolean; +} + +export function UtilisationChart({ + weeks, + overallOfferedSlots, + overallBookedSlots, + overallUtilisationRate, + sampleSizeWarning, +}: UtilisationChartProps) { + const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); + + const maxSlots = Math.max(1, ...weeks.map((w) => Math.max(w.offeredSlots, w.bookedSlots))); + + return ( +
+
+
+

Slot utilisation by week

+

+ How efficiently your appointment calendar slots were booked across weeks. +

+
+
+ + +
+
+ + {sampleSizeWarning && ( +
+ Small sample caveat + + Only {overallOfferedSlots} slots offered in this period. Percentage utilisation figures can fluctuate significantly with limited data points. + +
+ )} + +
+
+ Offered slots + {overallOfferedSlots} + Total availability +
+
+ Booked slots + {overallBookedSlots} + Patient reservations +
+
+ Utilisation rate + {overallUtilisationRate.toFixed(1)}% + Booked / Offered +
+
+ + {viewMode === 'chart' ? ( +
+ + {/* Horizontal baseline */} + + + + + {/* Y axis labels */} + 0 + {Math.round(maxSlots / 2)} + {maxSlots} + + {/* Bars for each week */} + {weeks.map((w, idx) => { + const count = Math.max(1, weeks.length); + const groupWidth = 520 / count; + const xCenter = 50 + idx * groupWidth + groupWidth / 2; + const barWidth = Math.min(24, groupWidth / 2.5); + + const offeredHeight = Math.max(2, (w.offeredSlots / maxSlots) * 160); + const bookedHeight = Math.max(2, (w.bookedSlots / maxSlots) * 160); + + const offeredY = 190 - offeredHeight; + const bookedY = 190 - bookedHeight; + + return ( + + {/* Offered slot bar (surface/accent light) */} + + {/* Booked slot bar (accent pine green) */} + + {/* Rate label above bars */} + + {w.utilisationRate.toFixed(0)}% + + {/* X axis week label */} + + {w.weekLabel} + + + ); + })} + + +
+
+ + Offered slots +
+
+ + Booked slots +
+
+
+ ) : null} + + {/* Accessible Table alternative (rendered when table view is active, and also available for screen readers) */} +
+ + + + + + + + + + + + + + {weeks.map((w) => ( + + + + + + + + + ))} + +
Weekly breakdown of offered vs booked slots and utilisation percentage
WeekDate RangeOfferedBookedCompletedUtilisation
{w.weekLabel}{w.startDate} to {w.endDate}{w.offeredSlots}{w.bookedSlots}{w.completedSlots}{w.utilisationRate.toFixed(1)}%
+
+
+ ); +} diff --git a/src/features/insights/hooks/useDoctorInsights.ts b/src/features/insights/hooks/useDoctorInsights.ts new file mode 100644 index 0000000..ba718b0 --- /dev/null +++ b/src/features/insights/hooks/useDoctorInsights.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import * as insightsService from '../../../services/insights-service'; +import { useAuthStore } from '../../../store/auth-store'; +import type { DateRangePreset, DoctorPracticeInsights } from '../../../types/models'; + +export interface UseDoctorInsightsParams { + preset: DateRangePreset; + startDate?: string; + endDate?: string; +} + +export function useDoctorInsights(params: UseDoctorInsightsParams) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['doctor', 'insights', params], + queryFn: () => insightsService.getDoctorPracticeInsights(accessToken!, params), + enabled: Boolean(accessToken), + }); +} diff --git a/src/main.tsx b/src/main.tsx index 2d6b56c..e67f490 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'; // Revisit if this ever deploys somewhere with a real rewrite rule. import { HashRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useAuthStore } from './store/auth-store'; import App from './App'; import './styles/global.css'; @@ -18,6 +19,22 @@ const queryClient = new QueryClient({ }, }); +// Enable preview session when previewRole query param is set (useful for screenshots & review) +const urlParams = new URLSearchParams(window.location.search); +const previewRole = urlParams.get('previewRole'); +if (previewRole && (previewRole === 'doctor' || previewRole === 'facility' || previewRole === 'patient' || previewRole === 'admin')) { + useAuthStore.getState().setSession( + { + id: `preview-${previewRole}-user`, + email: `${previewRole}@docforum.org`, + role: previewRole, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + 'preview-token', + ); +} + const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('#root element not found'); diff --git a/src/pages/DoctorDashboardPage.tsx b/src/pages/DoctorDashboardPage.tsx index a342bb8..068d877 100644 --- a/src/pages/DoctorDashboardPage.tsx +++ b/src/pages/DoctorDashboardPage.tsx @@ -65,6 +65,16 @@ export function DoctorDashboardPage() { navigate('/doctor/consultation/new'); }, }, + { + id: 'insights', + label: 'Open practice insights', + category: 'navigation', + shortcut: 'mod+4', + run: () => { + setPaletteOpen(false); + navigate('/doctor/insights'); + }, + }, ]; return ( @@ -73,6 +83,9 @@ export function DoctorDashboardPage() {

Doctor dashboard

+ diff --git a/src/pages/DoctorPracticeInsightsPage.tsx b/src/pages/DoctorPracticeInsightsPage.tsx new file mode 100644 index 0000000..d004231 --- /dev/null +++ b/src/pages/DoctorPracticeInsightsPage.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom'; +import { PracticeInsightsView } from '../features/insights/components/PracticeInsightsView'; + +export function DoctorPracticeInsightsPage() { + return ( +
+
+ + ← Back to Doctor Dashboard + +
+ +
+ ); +} diff --git a/src/pages/FacilityDashboardPage.tsx b/src/pages/FacilityDashboardPage.tsx index 1a9a16f..71fffe6 100644 --- a/src/pages/FacilityDashboardPage.tsx +++ b/src/pages/FacilityDashboardPage.tsx @@ -1,19 +1,136 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; import { useAuthStore } from '../store/auth-store'; import { WalletLinkForm } from '../features/payments/components/WalletLinkForm'; +import { FulfillmentQueueWithAttribution } from '../features/facility-team/components/FulfillmentQueueWithAttribution'; +import { useFulfillmentQueue, useUpdateFulfillmentItem } from '../features/facility-team/hooks/useFacilityTeam'; +import type { FulfillmentQueueItem } from '../types/models'; +import styles from '../features/facility-team/components/FacilityTeam.module.css'; + +export const DEFAULT_MOCK_QUEUE: FulfillmentQueueItem[] = [ + { + id: 'queue-1', + facilityId: 'fac-main', + orderId: 'ord-rx-101', + orderType: 'prescription', + patientName: 'John Doe', + patientProfileId: 'pat-1', + itemName: 'Amoxicillin 500mg (30 capsules)', + instructions: 'Take 1 capsule three times daily with meals for 10 days.', + status: 'fulfilled', + receivedAt: '2026-10-18T08:30:00Z', + attribution: { + action: 'dispensed', + performedByUserId: 'user-owner-1', + performedByName: 'Dr. Evelyn Reed', + performedByEmail: 'evelyn.reed@centralpharmacy.org', + performedByRole: 'owner', + timestamp: '2026-10-18T09:15:00Z', + notes: 'Batch #B-9912 verified and dispensed with patient leaflet', + }, + }, + { + id: 'queue-2', + facilityId: 'fac-main', + orderId: 'ord-lab-204', + orderType: 'lab', + patientName: 'Alice Smith', + patientProfileId: 'pat-2', + itemName: 'Fasting Lipid Panel & HbA1c', + instructions: '12-hour fasting venous blood sample required.', + status: 'fulfilled', + receivedAt: '2026-10-18T09:00:00Z', + attribution: { + action: 'sample_collected', + performedByUserId: 'user-staff-1', + performedByName: 'Marcus Vance, PharmD', + performedByEmail: 'marcus.vance@centralpharmacy.org', + performedByRole: 'staff', + timestamp: '2026-10-18T10:05:00Z', + notes: 'Phlebotomy completed without complications', + }, + }, + { + id: 'queue-3', + facilityId: 'fac-main', + orderId: 'ord-rx-309', + orderType: 'prescription', + patientName: 'Robert Garcia', + patientProfileId: 'pat-3', + itemName: 'Metformin 850mg (60 tablets)', + instructions: 'Take 1 tablet twice daily with food.', + status: 'pending', + receivedAt: '2026-10-18T10:30:00Z', + attribution: null, + }, +]; -// Fulfillment queue (view assigned orders, mark fulfilled/rejected) is -// still Phase W4 — not started, see ROADMAP.md. The payout wallet link -// below is real (Phase W5, docforum-core's Phase 5.5). export function FacilityDashboardPage() { const user = useAuthStore((state) => state.user); + const facilityId = 'fac-main'; + + const { data: queueData } = useFulfillmentQueue(facilityId); + const updateMutation = useUpdateFulfillmentItem(facilityId); + + // Use local state if user performs actions in-session + const [localQueue, setLocalQueue] = useState(null); + + const displayQueue: FulfillmentQueueItem[] = localQueue ?? queueData ?? DEFAULT_MOCK_QUEUE; + + const handleQueueAction = async (itemId: string, action: 'fulfilled' | 'rejected') => { + try { + await updateMutation.mutateAsync({ itemId, action }); + } catch { + // Local fallback state update for demonstration when backend endpoint is not yet connected + setLocalQueue((prev) => { + const current = prev ?? DEFAULT_MOCK_QUEUE; + return current.map((item) => { + if (item.id === itemId) { + return { + ...item, + status: action, + attribution: { + action: item.orderType === 'prescription' ? 'dispensed' : 'sample_collected', + performedByUserId: user?.id ?? 'usr-current', + performedByName: user?.email.split('@')[0] ?? 'Staff User', + performedByEmail: user?.email ?? 'staff@facility.org', + performedByRole: 'staff', + timestamp: new Date().toISOString(), + }, + }; + } + return item; + }); + }); + } + }; return ( -
-
- Phase W4 · fulfillment queue not started -

Welcome{user ? `, ${user.email}` : ''}

-

Your fulfillment queue will live here — see ROADMAP.md Phase W4.

+
+ + +
+
+

Facility dashboard

+

+ Welcome{user ? `, ${user.email}` : ''} • Central Pharmacy & Diagnostic Center +

+
+ + +
); diff --git a/src/pages/FacilityTeamPage.tsx b/src/pages/FacilityTeamPage.tsx new file mode 100644 index 0000000..6eaa276 --- /dev/null +++ b/src/pages/FacilityTeamPage.tsx @@ -0,0 +1,21 @@ +import { Link } from 'react-router-dom'; +import { FacilityTeamView } from '../features/facility-team/components/FacilityTeamView'; +import styles from '../features/facility-team/components/FacilityTeam.module.css'; + +export function FacilityTeamPage() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/services/facility-team-service.ts b/src/services/facility-team-service.ts new file mode 100644 index 0000000..ba991ef --- /dev/null +++ b/src/services/facility-team-service.ts @@ -0,0 +1,84 @@ +import { apiFetch } from './api-client'; +import type { + FacilityMember, + FacilityInvite, + InviteMemberInput, + RemoveMemberResponse, + FulfillmentQueueItem, +} from '../types/models'; + +export function listFacilityMembers(facilityId: string, accessToken: string): Promise { + return apiFetch(`/facilities/${facilityId}/members`, { accessToken }); +} + +export function listFacilityInvites(facilityId: string, accessToken: string): Promise { + return apiFetch(`/facilities/${facilityId}/invites`, { accessToken }); +} + +export function inviteFacilityMember( + facilityId: string, + input: InviteMemberInput, + accessToken: string, +): Promise { + return apiFetch(`/facilities/${facilityId}/invites`, { + method: 'POST', + body: input, + accessToken, + }); +} + +export function resendFacilityInvite( + facilityId: string, + inviteId: string, + accessToken: string, +): Promise { + return apiFetch(`/facilities/${facilityId}/invites/${inviteId}/resend`, { + method: 'POST', + accessToken, + }); +} + +export function revokeFacilityInvite( + facilityId: string, + inviteId: string, + accessToken: string, +): Promise<{ success: boolean }> { + return apiFetch<{ success: boolean }>(`/facilities/${facilityId}/invites/${inviteId}`, { + method: 'DELETE', + accessToken, + }); +} + +export function removeFacilityMember( + facilityId: string, + memberId: string, + accessToken: string, +): Promise { + return apiFetch(`/facilities/${facilityId}/members/${memberId}`, { + method: 'DELETE', + accessToken, + }); +} + +export function getFulfillmentQueue( + facilityId: string, + accessToken: string, +): Promise { + return apiFetch(`/facilities/${facilityId}/fulfillment-queue`, { + accessToken, + }); +} + +export function updateFulfillmentItem( + facilityId: string, + itemId: string, + action: 'fulfilled' | 'rejected', + accessToken: string, + notes?: string, +): Promise { + return apiFetch(`/facilities/${facilityId}/fulfillment-queue/${itemId}/action`, { + method: 'POST', + body: { action, notes }, + accessToken, + }); +} diff --git a/src/services/insights-service.ts b/src/services/insights-service.ts new file mode 100644 index 0000000..9ebeac9 --- /dev/null +++ b/src/services/insights-service.ts @@ -0,0 +1,22 @@ +import { apiFetch } from './api-client'; +import type { DoctorPracticeInsights } from '../types/models'; + +export interface GetInsightsParams { + preset?: string; + startDate?: string; + endDate?: string; +} + +export function getDoctorPracticeInsights( + accessToken: string, + params?: GetInsightsParams, +): Promise { + const query = new URLSearchParams(); + if (params?.preset) query.set('preset', params.preset); + if (params?.startDate) query.set('startDate', params.startDate); + if (params?.endDate) query.set('endDate', params.endDate); + + const qs = query.toString(); + const endpoint = `/doctor/insights${qs ? `?${qs}` : ''}`; + return apiFetch(endpoint, { accessToken }); +} diff --git a/src/types/models.ts b/src/types/models.ts index d58254a..89ad46b 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -292,3 +292,139 @@ export interface AddAddendumInput { content: string; } +// --- Phase W3 — Doctor Practice Insights (issue #50, depends on docforum-core#124) --- + +export type DateRangePreset = '7d' | '30d' | '90d' | 'custom'; + +export interface DateRange { + preset: DateRangePreset; + startDate: string; // ISO date YYYY-MM-DD + endDate: string; // ISO date YYYY-MM-DD +} + +export interface UtilisationWeek { + weekLabel: string; + startDate: string; + endDate: string; + offeredSlots: number; + bookedSlots: number; + completedSlots: number; + utilisationRate: number; // percentage 0-100 +} + +export interface NoShowMetrics { + totalScheduled: number; + completedVisits: number; + noShows: number; + lateCancellations: number; + onTimeCancellations: number; + noShowRate: number; // percentage + lateCancelRate: number; // percentage + sampleSizeWarning: boolean; +} + +export interface ReferralTurnaroundMetrics { + totalSent: number; + totalAccepted: number; + totalPending: number; + averageAcceptanceHours: number; + medianAcceptanceHours: number; + breakdownUnder24h: number; + breakdown24to48h: number; + breakdownOver48h: number; + sampleSizeWarning: boolean; +} + +export interface MessageResponseMetrics { + totalThreads: number; + averageFirstResponseMinutes: number; + medianFirstResponseMinutes: number; + respondedWithin2hCount: number; + respondedWithin24hCount: number; + rateWithin2h: number; + sampleSizeWarning: boolean; +} + +export interface DoctorPracticeInsights { + doctorProfileId: string; + range: DateRange; + generatedAt: string; + utilisation: { + weeks: UtilisationWeek[]; + overallOfferedSlots: number; + overallBookedSlots: number; + overallUtilisationRate: number; + sampleSizeWarning: boolean; + }; + noShows: NoShowMetrics; + referrals: ReferralTurnaroundMetrics; + messages: MessageResponseMetrics; +} + +// --- Phase W4 — Facility Staff Accounts & Fulfillment Attribution (issue #51, docforum-core#75) --- + +export type FacilityMemberRole = 'owner' | 'staff'; + +export interface FacilityMember { + id: string; + facilityId: string; + userId: string; + fullName: string; + email: string; + role: FacilityMemberRole; + joinedAt: string; + isCurrentUser?: boolean; +} + +export type InviteStatus = 'pending' | 'accepted' | 'revoked' | 'expired'; + +export interface FacilityInvite { + id: string; + facilityId: string; + email: string; + role: FacilityMemberRole; + invitedBy: string; + invitedAt: string; + expiresAt: string; + status: InviteStatus; +} + +export interface InviteMemberInput { + email: string; + role: FacilityMemberRole; +} + +export interface RemoveMemberResponse { + success: boolean; + memberId: string; + sessionsRevoked: boolean; + message: string; +} + +export type FulfillmentOrderType = 'prescription' | 'lab'; +export type FulfillmentItemStatus = 'pending' | 'in_progress' | 'fulfilled' | 'rejected'; + +export interface FulfillmentActionAttribution { + action: 'dispensed' | 'sample_collected' | 'verified' | 'rejected'; + performedByUserId: string; + performedByName: string; + performedByEmail: string; + performedByRole: FacilityMemberRole; + timestamp: string; + notes?: string | null; +} + +export interface FulfillmentQueueItem { + id: string; + facilityId: string; + orderId: string; + orderType: FulfillmentOrderType; + patientName: string; + patientProfileId: string; + itemName: string; + instructions?: string | null; + status: FulfillmentItemStatus; + receivedAt: string; + attribution?: FulfillmentActionAttribution | null; +} +