Spec: Inventory UX Polish, Smart Alerts, SWR Cache, Permission Fix
Date: 2026-04-29Scope: Four independent features shipped as one branch.
1. SWR Cache — Skeleton under 2 seconds
Problem
Every module navigation triggers fresh API fetches because React unmounts components and discards state. The existingserverComm.ts cache deduplicates in-flight requests but does not persist data between navigations.
Solution
Extend the existing in-memory cache map with TTL + stale-while-revalidate (SWR): Cache entry shape:- On
fetchWithAuthhit with a warm cache entry → return cached data immediately, then fire a background refresh (no loading state shown). - Background refresh: on success, update cache silently. On 401/compromised-session, dispatch
auth:force-logoutas today — auth errors always propagate. On any other error (network, 5xx), keep stale data and swallow — no crash. - On cache miss (first visit or TTL expired and no stale entry) → fetch normally, skeleton shows once.
inFlightflag prevents duplicate concurrent fetches for the same key while stale data is being served.
/inventory/items/... clears /inventory/items and /inventory/summary).
Never cached: 401, 403, non-2xx responses (already excluded).
Endpoints covered by SWR TTL:
/inventory/items,/inventory/summary,/inventory/alerts,/inventory/suppliers/patients/appointments/lab-cases,/laboratories,/lab-services/services/expenseslist
fetchWithAuth → token refresh → 401 propagation chain. No new error surfaces are introduced.
2. Clinical Notes 403 Fix — Reception Role
Problem
AppointmentDetailPage.tsx:98 and PatientDetails.tsx:~170 both call getClinicalNotesByPatient() unconditionally regardless of role. The render is gated correctly (!isReceptionist && has('clinical.notes.view')) but the fetch fires anyway, generating 403 worker logs and unnecessary error noise. The .catch(() => {}) already prevents crashes/logouts but the call should never happen.
Solution
Wrap both fetch calls with a guard before firing:AppointmentDetailPage.tsx: addif (has('clinical.notes.view'))before thegetClinicalNotesByPatient(...)call. Thehas()hook is already imported.PatientDetails.tsx: addif (!isReceptionist)(already in scope at that line) before the dynamic-import chain that callsgetClinicalNotesByPatient.
3. Inventory: New Item Full Page + Lab Preset
Problem
“New item” opens a compact modal (InventoryItemDialog). Other modules (patients, lab cases, appointments) use full-page forms. The supplier field is free text — clinics already maintain a laboratory catalog in Settings that should be reused.
Solution
Full-page new/edit: Extend the existing?item=<uuid> URL-param pattern. When the param is new, render a new InventoryItemFormPage component instead of InventoryItemPage.
InventoryPage changes:
- “New item” button →
setSearchParams({ item: 'new' })instead of opening dialog. - “Edit item” in row dropdown →
setSearchParams({ item: id, edit: '1' })(full page, same as create). - Both render
InventoryItemFormPagewhenitemId === 'new'oredit === '1'.
InventoryItemFormPage:
- Extracted form fields from
InventoryItemDialog(same fields, same save logic, same validation). - Full-page layout: back arrow + page title, two-column grid on desktop, single column on mobile.
- On save success →
setSearchParams({})(back to list) + toast. - On cancel →
setSearchParams({}).
Input with a Select that calls getLaboratories() (already in serverComm) on mount.
- Shows lab name + (optional) phone.
- Falls back gracefully: if fetch fails or returns empty, shows a
+ Add lab in Settingshelper link and allows free-text entry as a fallback. - The existing
InventoryItemDialogmodal is removed entirely. Both create and edit use the same full-page form.
InventoryItemDialog — can be deleted once full-page edit is wired up. No other consumers.
4. Smart Alerts: Stock Alert Email + EOD Email
4a. Opt-in Settings Schema
Add a new jsonb column toclinics table:
- Inventory section in Settings → “Email me when stock drops below reorder point” toggle.
- EOD / Daily Close section in Settings → “Email daily EOD report at 9 PM” toggle.
PATCH /clinics/:id (existing endpoint) writing to notificationPreferences.
4b. Stock Alert Email
Trigger: On any stock-write path ininventory.ts (receive, consume, adjust) — after the DB write succeeds, check if item.quantity ≤ item.reorderPoint AND clinic.notificationPreferences.stockAlertEmailEnabled === true.
Dedup: Add emailSentAt timestamp column to inventory_alerts table (migration). Only send if no alert email was sent for this item in the last 24 hours (emailSentAt IS NULL OR emailSentAt < NOW() - INTERVAL '24 hours').
Flow:
- Stock write completes.
- Fetch updated item qty.
- If qty ≤ reorderPoint: upsert
inventory_alertsrow (type:reorder) and check dedup. - If dedup passes: fetch clinic admin email, render
StockAlertEmailtemplate, send via ZeptoMail, updateemailSentAt. - Fetch all admin users of the clinic (not just primary email), send to each. All in same request, fire-and-forget pattern (don’t await email send, use
.catch(console.error)so stock write never fails due to email error).
server/src/emails/StockAlertEmail.tsx
- Item name, current qty, reorder point, suggested reorder quantity, clinic branding.
- CTA: “View Inventory” deep link.
4c. EOD Email with AI Summary + PDF
Cron trigger: Add0 16 * * * to wrangler.toml (16:00 UTC = 21:00 PKT). This slot is not in the existing set (0 4, 0 9, 0 14, 0 19).
New scheduled handler: server/src/scheduled/eod-email-report.ts
server/src/emails/EODReportEmail.tsx
- Clinic name, date, AI summary (markdown rendered as plain text sections).
- Key figures: total revenue, total expenses, net, appointments seen.
- “View full report in OdontoX” CTA.
- PDF attached as
EOD-Report-YYYY-MM-DD.pdf.
expenses.ts into server/src/lib/eod-report.ts so both the HTTP route and the scheduled job use identical data-fetching logic.
scheduled.ts wiring: Add a new branch for 16:00 cron trigger that calls handleEODEmailReport.
Data migrations required
| Table | Change |
|---|---|
clinics | Add notification_preferences jsonb column |
inventory_alerts | Add email_sent_at timestamp column |
Files touched (summary)
| File | Change |
|---|---|
ui/src/lib/serverComm.ts | SWR cache upgrade |
ui/src/components/appointments/AppointmentDetailPage.tsx | Permission guard on clinical notes fetch |
ui/src/components/patients/PatientDetails.tsx | Permission guard on clinical notes fetch |
ui/src/components/inventory/InventoryPage.tsx | New item → full-page nav, remove dialog wiring |
ui/src/components/inventory/InventoryItemFormPage.tsx | New: full-page create/edit form with lab select |
ui/src/components/inventory/InventoryItemDialog.tsx | Delete (replaced by full-page form) |
ui/src/components/settings/InventorySettings.tsx | New: opt-in toggle for stock alerts |
ui/src/components/settings/SettingsModule.tsx | Register InventorySettings section |
server/src/schema/clinics.ts | Add notificationPreferences jsonb |
server/src/schema/inventory_alerts.ts | Add emailSentAt timestamp |
server/src/routes/inventory.ts | Post-write stock alert trigger |
server/src/routes/expenses.ts | Extract EOD query to shared helper |
server/src/lib/eod-report.ts | New: shared EOD query helper |
server/src/emails/StockAlertEmail.tsx | New: email template |
server/src/emails/EODReportEmail.tsx | New: email template |
server/src/scheduled/eod-email-report.ts | New: scheduled handler |
server/src/scheduled.ts | Wire 16:00 cron to EOD email handler |
server/wrangler.toml | Add 0 16 * * * cron trigger |
server/src/lib/email.ts | Add sendStockAlertEmail, sendEODReportEmail functions |
Out of scope
- Push/in-app notifications for stock alerts (future).
- Per-staff email routing (currently always goes to clinic admin).
- EOD email for non-admin roles.
- React Query migration (deferred).

