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.
+
+ Send an invitation email to a staff pharmacist or co-owner. They will receive a link to activate their account and join this facility.
+
+
+
+
+
+ );
+}
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
+ ) : (
+
+
+
+
+
Invited email
+
Role
+
Invited by
+
Sent
+
Expires
+
Actions
+
+
+
+ {invites.map((invite) => (
+
+
{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.
+
+ 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.
+
+
+ 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.
+
+
+ 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
+