Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<table>` 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.


9 changes: 8 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 `<table>` 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.


Binary file added screenshots/doctor-dashboard-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-practice-insights-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-practice-insights-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/facility-fulfillment-queue-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/facility-team-owner-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/facility-team-owner-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -45,6 +47,11 @@ export default function App() {
<DoctorDashboardPage />
</RequireAuth>
} />
<Route path="doctor/insights" element={
<RequireAuth allowedRoles={['doctor']}>
<DoctorPracticeInsightsPage />
</RequireAuth>
} />
<Route path="doctor/consultation/:appointmentId" element={
<RequireAuth allowedRoles={['doctor']}>
<ConsultationNote />
Expand All @@ -55,6 +62,14 @@ export default function App() {
<DoctorInbox />
</RequireAuth>
} />
<Route
path="facility/team"
element={
<RequireAuth allowedRoles={['facility']}>
<FacilityTeamPage />
</RequireAuth>
}
/>
<Route
path="facility/*"
element={
Expand Down
20 changes: 20 additions & 0 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ export function Layout() {
</Link>
</>
)}
{user.role === 'doctor' && (
<>
<Link to="/doctor" className={styles.navLink}>
Dashboard
</Link>
<Link to="/doctor/insights" className={styles.navLink}>
Insights
</Link>
</>
)}
{user.role === 'facility' && (
<>
<Link to="/facility" className={styles.navLink}>
Queue
</Link>
<Link to="/facility/team" className={styles.navLink}>
Team
</Link>
</>
)}
<span className={styles.user}>
{user.email}
{user.role === 'doctor' && <UnreadBadge />}
Expand Down
102 changes: 102 additions & 0 deletions src/features/facility-team/components/FacilityMemberList.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>;
isRemoving: boolean;
}

export function FacilityMemberList({
members,
isOwner,
onRemoveMember,
isRemoving,
}: FacilityMemberListProps) {
const [selectedMember, setSelectedMember] = useState<FacilityMember | null>(null);

const ownerCount = members.filter((m) => m.role === 'owner').length;

return (
<section className={styles.card} aria-labelledby="members-heading">
<div className={styles.cardHeader}>
<div>
<h2 id="members-heading">Facility members</h2>
<p className={styles.headerSubtitle}>
Current staff and owners with access to this facility.
</p>
</div>
</div>

<div className={styles.tableWrapper}>
<table className={styles.dataTable} aria-label="Facility team members">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Joined</th>
{isOwner && <th scope="col" style={{ textAlign: 'right' }}>Actions</th>}
</tr>
</thead>
<tbody>
{members.map((m) => {
const isLastOwner = m.role === 'owner' && ownerCount <= 1;

return (
<tr key={m.id}>
<td>
<strong>{m.fullName}</strong>
{m.isCurrentUser && <span className={styles.youBadge}>You</span>}
</td>
<td>{m.email}</td>
<td>
<span
className={`${styles.memberRoleBadge} ${
m.role === 'owner' ? styles.badgeOwner : styles.badgeStaff
}`}
>
{m.role}
</span>
</td>
<td>{new Date(m.joinedAt).toLocaleDateString()}</td>
{isOwner && (
<td className={styles.actionCell}>
<Button
variant="secondary"
onClick={() => setSelectedMember(m)}
disabled={isLastOwner}
title={
isLastOwner
? 'Cannot remove the last owner of a facility'
: `Remove ${m.fullName}`
}
aria-label={`Remove member ${m.fullName}`}
style={{ fontSize: 'var(--text-xs)', padding: '0.2rem 0.6rem' }}
>
Remove
</Button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>

<RemoveMemberModal
member={selectedMember}
onClose={() => setSelectedMember(null)}
onConfirm={onRemoveMember}
isPending={isRemoving}
isLastOwner={Boolean(selectedMember?.role === 'owner' && ownerCount <= 1)}
isOwner={isOwner}
/>
</section>
);
}
Loading
Loading