New Schedule wizard polish, CAD QR scan, patient card labels, Director/Programme Manager roles - #18
Conversation
- Add System Manager staff onboarding page: creates User + Healthcare Practitioner in one call instead of four manual Desk steps - Convert CAD/Doctor/Nurse from website pages to Desk Pages with full Desk chrome (sidebar, search, notifications) - Grant Doctor/Nurse/CAD roles read access on Page doctype - Gate patient queueing on clinic session status = In Progress, so a patient can no longer reach the doctor before the nurse starts session - Add Details/Order Test/Prescribe Medicine dialogs for doctor, and Enter Results/Dispense dialogs for nurse, carrying real structured clinical data instead of a bare state flip - Add doctor get_session_status/session bar for parity with CAD/Nurse - Add CAD/Doctor/Nurse/Onboard Staff shortcuts to the Admin workspace
Restrict the CAD, Doctor and Nurse workspaces to their own role plus System Manager, and the remaining workspaces to System Manager only. Framework, Quality and Marley Health are Desktop Icon records owned by frappe, erpnext and healthcare, so they cannot be restricted by editing those apps. desk_visibility runs on after_migrate instead, because each app re-syncs its own icon fixture on every migrate and would otherwise wipe the restriction. Clicking a CAD, Doctor or Nurse workspace icon now routes straight to the working page rather than an icon grid. The include path carries an explicit version, since app_include_js raw paths are not cache-busted by the framework and browsers keep serving the previous file.
Clinic ID is now LSG(2) + Unit(1) + Year(2) + Serial(5), replacing the BMC-##### series for newly registered patients. IDs already issued are left untouched, and both formats stay searchable. The serial resets per calendar year and runs global across every LSG and unit. Scoping it per LSG or unit would tie a permanent identifier to attributes that get corrected and reorganised, forcing an issued ID to either change or start lying. It is drawn from the Series counter rather than max()+1, which races when two front desks register at once, and it throws instead of widening past five digits. lsg_code and unit_code hold text like LSG-EKM-KLM, so the numeric components live in new fields alongside them. Both become immutable once any ID has been issued, because every printed card embeds them. Bandhu Clinic Session gains a unit link. Nothing in the registration path could resolve a unit before; deriving it from Unit.cad breaks as soon as one CAD covers two units. register_patient now takes the session and is gated on session access rather than role alone, since the session decides which codes land in a permanent ID. The QR encoded an API URL, so scanning a card produced a JSON page. It now carries the bare Clinic ID, which is what a USB barcode scanner needs and what a phone camera can show a human. A patch reissues the images for existing patients. New Bandhu Patient Card print format at CR80 size, reachable from the CAD screen through a whitelisted endpoint: the role holds no Patient print permission, so /printview refuses it. Scanning a card jumps straight to the patient on an exact match, and every queue row carries the grouped Clinic ID and its own print action.
Captures work that had accumulated uncommitted across several sessions, plus this round's security and correctness fixes. Scheduling - Bandhu Session Schedule (+ weekday child, Bandhu Settings single) and utils/session_schedule.py generate recurring camps weekly, fortnightly or monthly. Idempotency keys on (schedule, date), so a camp cancelled for a holiday is not regenerated by the nightly job. - page/new_schedule: guided Where/When/Who/Check wizard, with clash warnings when a doctor, nurse or vehicle is already committed. - page/my_schedule: field staff see the camps they are on the team for, drivers included. Cancelled camps are shown and badged, not hidden. - First use of scheduler_events in this app. Dashboard - Six number cards and a weekly chart on the Dashboard workspace, following the Number Card + Dashboard Chart pattern used elsewhere in the org rather than a custom page. Security and correctness - Escape patient_name, mobile and the clinic ID in the patient card print format. Frappe's Jinja environment has autoescape off, and the card is rendered into a same-origin window, so a name entered at registration could run as script in the session of whoever printed it. - register_patient now requires a running camp, sharing one gate with create_encounter. The camp resolves the LSG and unit codes baked into a permanent, printed Clinic ID. - Nurse start/end camp is now a state machine: no reopening a closed camp, no opening one dated another day, nothing on a cancelled camp. - sync_to_queue survives two front desks registering the same patient at once. Patient Queue.patient is unique and DuplicateEntryError is not a ValidationError, so the existing handler never caught it and the whole registration rolled back. Display - Ages read 52y / 8mo / 12d instead of Healthcare's three-line "52 Year(s) 4 Month(s) 16 Day(s)". - Camp site shows its name, not its record id. - Queue tables no longer clip the last patient behind a 360px box. 106 tests pass.
Whitelisted methods no longer return {"success": True} — the absence of an
error is the success signal, so the client stops branching on a flag that only
ever held one value. frappe.throw's explicit frappe.ValidationError argument
goes with it; the default is the same exception.
Each Desk page's styles move out of the JS template string into a sibling
.css file.
First of the eight reports in the scope doc. One row per camp for a period: where and when, the team, opening hours, patients seen split into new and repeat, tests ordered vs done, medicines prescribed vs dispensed. Summary band, per-camp chart, and a shortcut on the Dashboard workspace. Roles are System Manager only. A Script Report runs raw SQL and bypasses permissions entirely, so the Report record's roles table is the only gate. Counts come from the encounter's own child tables. Nothing in the clinic loop writes Test Result, Bandhu Medication Dispense or Referral — the rows in those doctypes are pre-handoff seed data pointing at session ids, so joining them returned zero for every camp while looking correct. Test Instructions.result_type had no blank first option, so Frappe filled every newly ordered test with the first Select value: a malaria test nobody had run yet read as Positive on the boards and in the report. The options now start blank, and the patch clears the rows the old schema mislabelled — only where the encounter is still Awaiting Test, so a nurse's real result is untouched.
Tests Report is one row per test: result and value alongside the patient's Clinic ID, sex, age group and native state, and the camp it was run at. The Pending filter matches the absence of a result, so it is applied in Python rather than as a SQL equality. Clinic Report aggregates the same camps by clinic, project, unit, LSG or site, with camps scheduled vs held vs cancelled and patients per camp. The grouping picks a field, so it is validated against a whitelist rather than trusted. The four per-camp count queries move to utils/clinic_stats.py, now that Session and Clinic Report both run them. age_group() lands in utils/patient.py with bands we chose — the scope doc asks for age-group breakdowns without defining them, so CMID still has to confirm.
Patients Per Camp, Tests By Result and Patients By Clinic render the existing Session, Tests and Clinic reports rather than re-querying, so a change to a report's logic cannot drift from its chart. Each carries a rolling 30-day window as a dynamic filter, since a fixed from_date would freeze the moment the JSON was written. The quick list and the camps chart move to full width. At half width the three new charts sat in a ragged two-column grid with unreadable category labels.
…ests By Result series
- Patients By Clinic threw 'Unknown grouping: clinic' — the chart's
filters_json passed lowercase 'clinic' but Bandhu Clinic Report's
GROUP_BY_FIELD keys are capitalized ('Clinic').
- Patients Per Camp drew one bar per session, so several camps sharing
a date truncated the x-axis into unreadable stubs. Now grouped by
day.
- Tests By Result always drew a 'Positive' series even when nothing
was positive, showing an empty legend entry. Now stacks whichever
result types actually occur (Positive/Negative/Value/Pending) and
drops series with no data.
…ields - Where > When > Who > Check fields reordered to the real hierarchy (Project > Site > Clinic > Unit); Site/Clinic/Unit narrow to what has actually been scheduled together before, falling back to the full list when there is no history yet instead of blocking a new combination. - Project and Unit are now required, matching Site and Clinic; Project is derived server-side from the selected Clinic in the DocType's own validate() so the Desk form and any other caller get it too, not just this wizard's JS. - Who step (Doctor/Nurse/Driver/Vehicle) is now required before Next. - Top progress bar replaced with a real numbered stepper. - 'Next dates' renders as a timeline instead of a flat list. - 'Already assigned elsewhere' moved out of the narrow sidebar into a full-width table below the wizard, shown only on the Who step where it is relevant, with Role/Name/Site/Date columns and a thin separator under the header row. - Check step adds a Next 4 Weeks table (Date/Site/Clinic/Unit/Time). - Wizard buttons/pills/day-chips now use Frappe's own button CSS variables instead of hardcoded colors.
- Add a Scan button next to patient search that opens Frappe's built-in Scanner (html5-qrcode, already bundled in Desk) to decode a patient card's QR with the device camera and run the same lookup a typed or USB-scanned Clinic ID already goes through. Guards on navigator.mediaDevices being available so a browser/origin that can't do camera access gets a clear message instead of a silent failure. - Native District now offers suggestions from get_districts(), filtered by the selected Native State, via a datalist on the existing free-text field -- no change to it staying free text.
Sex/DOB/Mobile were printed as bare values with no label, unreadable at a glance. Every field now reads Label: value, including a new ABHA ID line (shown only when the patient has one). All still pass through the existing | e escaping this print format depends on.
…status No Director or Programme Manager role existed anywhere in the system, so the Dashboard workspace and all 3 scope reports were System Manager only. New patch creates both roles and grants them read on Bandhu Clinic Session -- the real gate: a Workspace's own roles list only narrows visibility after the user already has read permission on some doctype in that module (Workspace.is_permitted() in Frappe core runs after a module check), so without this the Dashboard workspace never showed up in the sidebar for either role and /app/dashboard fell through to the core Dashboard DocType's list view instead. Added to the Dashboard workspace and Session/Tests/Clinic report roles. custom_encounter_status was never written by any app code -- only its own Select default filled it, so all 41 live encounters read 'Registered' regardless of their actual state. Removed the field definition and fixed the two dangling insert_after references; a patch drops the Custom Field record and the now-orphaned column.
The page stylesheets referenced --border-radius-full, --border-radius-md and --border-radius, none of which frappe v17 defines. An undefined custom property makes the whole declaration invalid, so every badge and card fell back to square corners. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UcoFSie72rSSkiKmPJFySt
…ent card Both are attribute-context values in a template whose Jinja env has autoescape off; one bad quote in custom_bandhu_id would break out of the alt attribute.
Every patient in the DB is readable through the CAD form's search and printable card. That stays — a CAD legitimately meets patients registered at another site, and narrowing the search would break repeat-visit lookup. What was missing is any record of who looked at whom, so both endpoints now write through Frappe's own frappe.core.doctype.access_log.make_access_log, which defers the insert and so costs the request nothing. The card render is logged with the patient as the reference document: it names one patient and puts their details on paper. The search is logged with the term, because it is not keystroke-driven — cad_form.js fires it on Enter or the search button only (cad_form.js:322-327) — so it is one row per deliberate lookup, not a flood. Log Settings clears Access Log on its own retention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was whitelisted with no guard, so any authenticated user could call it. The data is a static district list, so the fix is the gate the rest of the form already uses, not a redesign. Its only caller is the CAD registration form's native-state change handler (cad_form.js:511), which require_cad_access() lets through unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Patient Queue.patient is unique and sync_to_queue overwrites the row on every visit, so one row per patient ever registered sat on the board for the life of the site with nothing to remove it. Patient Queue is a projection — Patient Encounter holds the clinical record and sync_to_queue rebuilds the row from it on the next save — so a finished row can be dropped and nothing is lost. Implemented as the clear_old_logs interface Frappe's Log Settings already looks for (frappe/core/doctype/log_settings), registered via default_log_clearing_doctypes, rather than as a scheduled job of our own. Only Done rows past the retention window are touched; Active rows stay whatever their age, so an unclosed camp keeps its board. The Data-typed patient/clinic_session columns are left alone — changing them to Link touches a unique index and is a schema change, not this cleanup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sync_to_queue's savepoint recovery caught frappe.DuplicateEntryError, which base_document only raises for a primary-key collision (base_document.py:837). A unique *field* goes through show_unique_validation_message() and raises frappe.UniqueValidationError instead (base_document.py:917), and Patient Queue keys its unique index on `patient`, not on the name — so the loser of a concurrent registration escaped the handler entirely, propagated out of the on_update hook and out of encounter.insert(), and rolled the whole registration back. Both classes are caught now: the row is also named by a dated series two same-second inserts can still collide on. The recovery clears the framework's queued "must be unique" msgprint, which would otherwise report a failure the front desk did not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Consolidation note (2026-08-26) — #10, #11, #12, #13, #14, #15, #16 and #17 have been closed as superseded by this PR, with the reason recorded on each one. Why: this branch was opened on the same base as #10 and already contains every commit from all eight of those PRs, unchanged and by the same SHA, plus five newer ones. They were eight windows onto code this PR already carries — separate review meant reviewing the same diff twice, and merging them in any order would have conflicted against this branch. Each closure was verified with So this PR is now the single review surface for the whole stack. #9 (README contributing section) is unrelated and was left open. Follow-up work sits on |
|
Follow-up branch is now up as #19 (draft) — it is stacked on this branch, so it should merge after this one. It closes the remaining
Nothing here needs changing — #19 carries all of it. |
A Time read from the DB is a timedelta, but one arriving from a client save is a string, and "13:30:00" <= "8:00:00" is True lexicographically - so every schedule starting before 10am was unsavable from the Desk form.
/desk renders the Desktop Icon grid, and Frappe seeds icons from workspaces only in after_app_install - which ran before these workspaces declared their app, so field staff had no icon for their own board. The old restrict list named three icons by hand, missed every ERPNext one, and named one that does not exist here.
link_to resolves against Workspace Sidebar, not Workspace, and the two sync separately - from after_migrate an unresolvable link took the whole migrate down.
is_icon_permitted resolves a link icon through bootinfo.workspace_sidebar_item, which is keyed by app and never by workspace name, so per-workspace icons were created and filtered straight back out. The app tile is the route; the sidebar behind it is already role-filtered.
Frappe's standard.css sets .table { margin: 20px 0 }; these files zeroed only
margin-bottom, so the surviving 20px showed as an empty band inside the bordered
wrap above every header row. Page containers also never gained the bottom
padding that removing the .table-wrap height cap implied.
A sticky offset resolves against the scroll container's content box, so top:0 parked the clash header 15px inside .clash's padding and rows scrolled through the gap - which is what read as the header breaking apart.
…e wizard One bullet per person per date is unreadable at forty rows, and re-warning about clashes the wizard already showed is how real warnings get ignored.
Every line carried the same weight, so the one thing the row is opened to answer - where am I going - read no louder than the nearest PHC.
The five tests were hardcoded in four places and had already drifted apart. They are now records: the doctor's checkbox grid, order_test validation and the Tests Report filter all read the master, and Test Instructions.test_name is a Link to it. The master carries the result shape (Positive/Negative vs a measured Value, with its unit), so the shape stops being knowledge each form has to hold — that split is what let an untested test read "Positive" until 2026-08-20. Seeded via after_install for new sites and an idempotent patch for the two existing ones; a retired test keeps enabled = 0 across re-seeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IntegrationTestCase rolls back once the class finishes, not per test, so a test that retires a master was still visible to the next one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MultiCheck re-sorts its options alphabetically unless sort_options is false, so display_order never reached the screen. Caught in the browser, not by the tests, which assert the server's order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
order_test stamps the doctor's one ordering note onto every row it appends, so the Patient Details dialog printed the same sentence once per test. shared_test_note reports that note once when every row agrees, leaving the per-row notes in the data for the rows that genuinely carry their own. attach_test_shapes carries result_shape and unit down from the Bandhu Test master in one query for the whole batch. A row cannot say which shape it is — an ordered but untested row has no result_type at all — so the boards could not tell a measurement from an indicator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…markup
Nine full-width label/value rows separated by rules is heavier ruling than anything
Desk draws. The dialog now uses Desk's own read-only field shape — a small muted
control-label above its value, in the bootstrap grid and espresso typography the desk
bundle already ships — so it follows Desk across an upgrade instead of drifting from
it. Height, weight and BMI collapse into one Vitals field rather than three more rows.
Two display bugs go with it:
- Date of Birth printed the stored 1992-02-14. It now goes through
frappe.datetime.str_to_user, like every other date in this app.
- Tests read as a bullet list with a literal double dash and the status as plain
text ("Hb -- pending"), and the ordering note dangled under every row. They now
read as rows — name left, result right — with es-badge indicators: Positive is the
only solid badge, because it is the one a nurse must not walk past. A value test
prints its reading and the master's unit (11.4 g/dL) instead of the word "Value",
and a per-row note is labelled rather than left hanging under its test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.detail-row and .detail-heading had no callers left once the dialog moved onto Desk's own classes, and page rules for a frappe.ui.Dialog are exactly what drifts from Desk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Font Awesome is not in this bench's Desk bundle (`grep -c "fa-calendar-o" sites/assets/frappe/dist/css/desk.bundle.*.css` returns 0), so every `<i class="fa fa-...">` this app shipped rendered as an empty element. Eight were left after the earlier conversion pass: Start/End Session on the nurse page, the QR-scan and register buttons on CAD, and the retry-screen warning, clinic, site and session-status glyphs in the shared session UI. All now go through `frappe.utils.icon()` against ids verified to exist in frappe/public/icons/lucide/icons.svg. The session status was the worst of them: with the dot invisible, "In Progress" and "Completed" differed only by the word. It is now an `.es-badge` rather than a restored dot — a dot carries its meaning only in colour, which fails a colour-blind reader and a phone screen in sunlight, and these boards are used outdoors. Two things the swap exposed. Desk's own `.icon` carries `margin: 0 auto`, which inside the flex session bar let the icons absorb the free space and drift away from their labels, so the bar pins it to 0. And `.load-error-icon` sized its glyph with `font-size`, which does nothing to an SVG; it now sets width/height.
Healthcare Practitioner declares title_field: practitioner_name but never sets show_title_field_in_link, so every Desk Link to a practitioner showed HLC-PRAC-2026-00004. Shipped as a Property Setter, since editing the healthcare app's JSON would be lost on the next bench update. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five top-level custom buttons crowded the form header and truncated the breadcrumb to "Ban /". The three that act on generated camps move under a Sessions group, Copy This Schedule under Create; Preview Next Dates stays top-level as the only read-only check and the only button on an unsaved doc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Desk leaves 27px under a workspace's last widget — --padding-sm on .layout-main-section plus the 20px floor it forces on editor.js's redactor — so the Dashboard's last row of shortcuts sat flush against the viewport. Adds the same --padding-2xl footer the Bandhu Desk pages carry, scoped per workspace so the other installed apps keep Desk's own spacing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quick list, a link field and the form header all read Bandhu Clinic Session's title field, which was the raw `site` link -- so every camp showed as SITE-0002. The new fetched `site_name` carries the readable name; a patch backfills the existing rows, since fetch_from only fires on save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five test names against four result series answered no question anyone asks of the dashboard; the Tests Report already carries the same breakdown with filters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Desk's .icon is an inline-block whose baseline is its bottom edge, so an icon set beside a label rode above the label's optical centre on every Bandhu page. The rule centres them once for all six page roots; its gap replaces the literal space the markup carried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cards led with the visit count and buried the one thing a doctor sorts by:
whether this patient is theirs to act on or is sitting with the nurse. Each card
now leads with that state, carries the Clinic ID and a readable clinical line
("Tests: awaiting Hb, Malaria") instead of "0/2 test(s) done", and keeps a fixed
action rail -- so a patient with nothing to do renders a reason, not a card with
a hole where the buttons would be. Completed Today drops to a table: no row
there is actionable, and a card each cost a screen per camp.
`group_clinic_id` moves into session_ui so both boards read one copy, and the
Clinic ID rides down in the query that already resolved each patient's age.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A patient who leaves before being seen stayed on the doctor's and nurse's boards for the rest of the camp with no way to clear them. Cancelled becomes a real workflow state, reachable from every live state and exiting to none, and the front desk gets the action against a running session it owns. Also in this pass: the schedule rail is drawn per item so it starts at the first bullet and stops at the last, and cad_form picks up the shared Clinic ID formatter now that it lives in session_ui. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reworked the New Schedule wizard - fields now go Project > Site > Clinic > Unit and each one narrows the next based on what's actually been scheduled together before. Made Project, Unit and all 4 Who fields required since people were skipping them. Swapped the plain progress bar for an actual stepper, next dates now show as a timeline, and moved the assigned-elsewhere clash warning out of that tiny sidebar into a proper table that only shows up on the Who step. Added a next-4-weeks table on the last step too.
Added camera QR scanning on the CAD page using Frappe's built in scanner, so staff without a USB scanner can just point a phone at the card. Tested it with an actual QR code through a fake camera and it correctly found the patient. Also wired up district autocomplete which existed on the backend but was never hooked up.
Patient card was showing raw values with no labels (just "Male 11-08-2026 9797646461") so added labels to everything and also added the ABHA ID which was missing.
Last thing - there was no Director or Programme Manager role anywhere so the dashboard and reports were stuck at System Manager only. Added a patch for both roles. Turned out just adding them to the workspace wasn't enough, they also needed read access on Bandhu Clinic Session or the dashboard wouldn't even show up in their sidebar. Also cleaned up custom_encounter_status, it was dead - nothing ever wrote to it so it just showed "Registered" on every encounter.
123/123 tests passing, checked everything live.
Same base as #10 since none of that is merged yet, so this has all of that plus today's commits.