- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
Adding a secretary meant deciding all 17 permission resources in the same
dialog. The full-height capture showed the form running past 1300px with the
save button below 16 accordions, and the dynamic registry makes that worse: every
page added in future lengthens this one modal.
The add/edit modal now carries only doctors, profile and address, and fits on
screen with its footer visible. Permissions move to SecretaryPermissionsModal,
reachable from a row action and opened automatically right after a successful
add, since a new secretary starts on the role defaults and the owner usually
wants to set them.
Neither create nor update sends permissions any more — the backend seeds the role
defaults on create, and the permissions modal owns the writes, fanning out over
every link row so a secretary shared across doctors stays consistent.
PermissionAccordions moves to components/ui as a shared component. Sections now
start collapsed with a granted/total badge on each header, so the panel opens at
a fixed height and still says which sections are on.
Two design-system slips caught by re-screenshotting rather than by the audit:
- a text button as a third row action pushed the name column out of the table, so
the desktop row uses an icon with a title and the mobile card keeps the label
- .btn.secondary is not defined in styles.css (variants are primary/ghost/soft/
danger/accent), so it renders as a bare .btn. Used ghost here. 25 other files
have the same dead class; left alone as a separate sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.
SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.
The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
actually manages (categories, item groups, service relations) — not resources
TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.
ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.
Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A finished session prints three areas with their device readings, so a course
with any history pushed the sessions that still need work off the screen.
Each session is a collapsible card now, with the scannable facts kept in the
head — number, status, date, staff, and an area count so opening is a decision
rather than a guess. History starts collapsed; a session that still needs
booking starts open, because its button is the reason it is on the page and
should not sit behind an extra click. A session with neither areas nor an
action has no body and renders as a plain row rather than an empty toggle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab stacked every course's full session list on one page. A protocol allows
sixty steps, so one open course was enough to bury the others. Courses are cards
now — service, progress, supervising doctor, staff — and opening one replaces
the list with its detail: a back button, two tabs (the whole course, or only
what is still to come), search and paging inside each. The choice lives in the
URL so browser-back returns to the same course.
Booking stays where the work is: the button sits on the session card inside the
upcoming tab, not on a separate page.
The patient banner's 'نوبت بعدی' read '—' for anyone mid-course, because it only
looked at booked appointments and a course's later sessions have none yet. It
now falls back to the next session of the active course and relabels itself
'جلسهٔ بعدی' when it does — a planned session is not a booking, and the banner
should not call it one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modal took its date from the caller and had no way to change it. Opened from
a session card, that meant the projected date or nothing: if the device had no
free time that day the user had to close a form they had already filled with a
patient and a service, go elsewhere, and start again.
The date is now a field in step one, seeded from the prop and reset when the
prop changes. ServiceSlotPicker already clears the picked slot when its date
changes, so the times reload for the new day on their own.
The header chip drops the date whenever that field is on screen; showing the
same value twice, one editable and one not, invites the reader to trust the
wrong one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab was called 'نوبتهای بعدی' but shows the whole course — finished sessions
with their recorded readings as much as upcoming ones. It is 'دورههای درمان' now.
Booking from a session still made the user search for a patient the page already
had open. The plan response carries the patient's national code (from the
profile, falling back to the user — the same COALESCE PatientController uses,
because users.national_code is routinely empty), and the modal takes a patient
prop that seeds the lookup and hides the search step. The old 'بیمار یافت شد'
card is suppressed in that mode; saying it twice is noise.
Sessions are now searchable and paged. A protocol allows up to 60 steps and a
patient can hold several courses, so an unbounded list was only ever going to
work for the small cases. Search filters on what the card actually shows —
service, staff, status, session number, area names — and runs in the page,
since /plan already returns the whole course and a round trip would add latency
and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The staff field started empty even when the service's treatment protocol already
named who may perform it — a decision made once in the service settings and then
asked again on every booking. The modal now reads that protocol and preselects
its first staff member, but only until the user touches the field; otherwise a
manual choice would be wiped on the next service change. A protocol with no
staff leaves it empty and does not block submission.
Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record
in /admin/staff that they all refer to. ResourcesPage keeps the word: there it
names a kind of bookable resource (doctor, operator, room, device), not a
ClinicStaff row.
Drops a test whose premise the default invalidated; the two new ones cover both
sides — protocol with staff sends staff_uuid, protocol without staff sends none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A patient's file had no view of their multi-session courses: which ones they
have, when the remaining sessions fall, or what was recorded in the sessions
already done. All of it lived on a tenant-wide page.
The tab lists the patient's courses and, per course, a card for every session
with its date, its status, and — for finished ones — the areas treated with the
device readings a staff member logged. Estimated dates are labelled as such, so
a projection is never read as a booking.
Each unbooked session carries a button that opens the same NewAppointmentModal
used elsewhere, seeded with that session's date, and now binds the resulting
appointment to that exact session via a new treatmentSessionUuid prop — a
patient can have several open courses, and without it the attachment falls back
to guessing from the service.
The modal opens in resource mode, not doctor mode: a course's service is booked
against the device's calendar, so useDoctorBookingServices returns nothing for
the supervising doctor and the picker would render 'no bookable services'. The
plan response now carries the course's device for exactly this. A course with no
device yet says so instead of offering a button that cannot work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list was cards only, so comparing courses across patients meant reading four
stacked blocks instead of scanning columns. It now has the same table/card
toggle the patients page uses, reusing that page's toggle icons and keeping the
choice in the URL so back and refresh hold it. Table is the default here: the
operator, status and session progress are the columns a manager scans.
Both views derive the operator the same way, through one helper — history
(performed_by) when a session has been done, otherwise the plan
(assigned_staff), labelled as not yet performed.
Also fixes a three-states slip in the operator picker I added last time: it
rendered "no staff defined" while the staff list was still loading, which is
what an empty list looks like from the user's side. Loading now says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.
Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.
Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.
Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.
Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Skipping an area recorded only that it was skipped. Why it was skipped is
clinical history — the next session needs to read it — so `skip` now takes an
optional note, the same way completing an area already did, and the panel asks
for it inline instead of firing on the first click.
An operator finds out mid-laser that they closed the wrong area, and until now
had to carry that mistake to the end of the session. `reopen` puts a settled
area — completed or skipped — back to in_progress and clears finished_at,
keeping the recorded parameters and note so they can be seen and overwritten.
It stops at the same boundary everything else in this domain stops at: once the
session is finished the record is history, and reopening it is 409.
Also drops /admin/my-services. The staff role has one job — today's sessions —
and the dashboard already lists the services they may perform, so the page was
a second place to read the same list. Route, page, sidebar entry and the two
links to it are gone; the services stat card is no longer a link because it no
longer has a destination.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.
Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.
Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.
`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.
Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.
Adds the test files neither the page nor the case editor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend already accepted staff_uuid on POST /api/v1/my/appointment; only
the booking modal never sent it, so the pre-assignment half of the operator
model had no way in.
The field sits in step 1 next to the section select and is optional by design:
left empty, the session stays in the shared queue that every allowed operator
sees. Its placeholder says so rather than leaving the blank state unexplained.
The section select is no longer the only combobox in the modal, so the tests
target it by its label instead of by role.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.
- Group the form into numbered steps (service+time, patient) so the order of
decisions is visible. The optional visit-price collapse stays unnumbered —
numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
`.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
fall through to "not enough free time", which sent users to another day for
no reason.
- Raise the service remove button (18px), the duration pill and the time chips
to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
full 40px box so the whole frame is clickable.
Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.
The redesign-page probe now names the offending elements instead of only
counting them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The day-offset field was a bare input inside `.field`, and `.field` is itself
the 40px bordered input box — so the number rendered as plain text with no
border and nothing said it could be edited. It now uses the shared Input in
numeric mode, which also normalises Persian digits on the way in.
Turning the switch off deleted the protocol, its steps and its staff list on a
single click with no confirmation. It now asks first, and says what will be lost
and what will not: open treatment cases are untouched.
A failed read left `protocol` null, which the tab rendered as a switch in the off
position — telling the manager this service is single-session when in truth the
request had simply failed. That state now shows an error with a retry.
Both icon-only buttons were hand-padded down to 18px; the runtime probe flagged
them in all four views. They use `.mini-btn`, which the design system already
sizes to 32px and to 44px on touch. The probe now reports one short control on
this page, and the same one on tabs this change never touched.
The save button sat at the bottom of a long form, so saving on a phone meant
scrolling past everything. `.save-bar` was already in the stylesheet for exactly
this; it now also states what is about to be saved, and why the button is
disabled when no staff are selected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting a session created area records with no device, and the panel only ever
read the device it never set — so every "اتمام این ناحیه" came back 422 with
"دستگاه این ناحیه مشخص نیست". The backend tests passed because they sent
resource_uuid explicitly; from the UI the flow was unusable end to end.
The device now inherits from the appointment's resource, which the secretary
already chose at booking; asking the operator again is taking one decision
twice. The session screen offers a picker per area on top of that, because one
session really does run bikini on an alexandrite and underarms on a diode.
Treating without a device is allowed: botox is an injection, and requiring a
device would make clinics invent a fake resource per injection. Sending readings
with no device is still rejected — there would be no schema to validate against.
A protocol whose service has no ResourceServiceOffering rows now says so in the
tab where the manager is standing. It does not block booking: "no offering means
any resource" is a deliberate, tested rule. But silence meant the gap surfaced
only when the operator was already in front of a patient.
Also adds the live timer the spec asked for, and wires slot-suggestions into the
unbooked queue — the endpoint existed and tested green but no screen called it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the panel side. A clinic picks its practice domain in settings, where
the copy says plainly that this is not the specialty label the public site shows;
picking nothing stays valid and changes nothing.
Cases and the unbooked queue share one page rather than two, because they answer
the same question — which patient is where in their course and what is still
owed. The queue explains why booking is not automatic instead of leaving the
reader to wonder.
Platform admins get domain CRUD with a column showing whether a domain has a
dedicated workflow or falls back to the default, so the gap is visible rather
than guessed at; the code field is locked after creation because workflows bind
to it.
Also puts the staff session screens in the sidebar — they were reachable only by
typing the URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three screens, each reusing what already exists rather than inventing a parallel
look. The treatment plan lives as a tab on the service page next to categories,
because the course belongs to the service; the switch is the protocol's existence
rather than a separate boolean that could disagree with the step list. Each step
asks for days since the previous session, which is how the interval actually
works and what the form should therefore say.
The staff screens are the flow from the reference screenshots: today's sessions,
then a session where each area is started, recorded and closed on its own. Field
inputs are built from the schema the server sends per resource type, so a clinic
adding an RF device sees its own form here without a code change.
StatusBadge gains treatment session and area states rather than a second badge
component sitting beside it, and the resource type modal grows a field editor so
the operator form is configured where the device is defined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes.
- Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels.
- Added tests for the new Switch component to ensure functionality and accessibility compliance.
- Updated styles to accommodate the new Switch component design.
- Add RecordNumberSettingsController for managing patient record number patterns.
- Create RecordNumberPattern entity to represent the pattern configuration.
- Implement RecordNumberPatternRepository for database interactions.
- Develop RecordNumberGenerator service for generating and validating record numbers.
- Add tests for record number generation, backfilling, and API interactions.
- Ensure proper access control for viewing and updating patterns based on user roles.
Resources never needed a branch: devices and rooms belong to the clinic
itself, and the picker always had exactly one option — a mandatory click
that decided nothing.
- `address_uuid` is now optional on resource and pool creation; when it is
missing the environment's own address is used. Clients still sending it
keep working.
- The panel no longer asks for or displays a branch anywhere: resource
form, list column and filter, pool form and column, detail row, and the
resource-first booking page.
- Availability no longer gates on `doctor_addresses.active`. That gate shut
down every device of a clinic whose address row happened to be inactive,
with a message no page in the panel could act on — no endpoint writes
that column at all.
`address_id` stays on the resource: the timezone and the tenant pair are
derived from it. It is simply no longer the user's decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
Two reported bugs, one root cause: resource tabs were built as a rival selection
to the doctor rather than a narrower view within them.
Selecting a resource cleared the doctor. The auto-select effect then quietly put
the *first* doctor back, so anyone working under the second doctor was thrown to
the first and lost that doctor's own booking. Selecting a resource now leaves the
doctor alone; only picking a doctor clears the resource.
A resource tab also still rendered the doctor's slot timeline, just with no data.
Resources have no slotted weekly schedule — their calendar comes from service
duration and real occupancy — so showing a slot grid promises times the booking
engine does not recognise. The resource tab now renders its own panel: that day's
appointments on the resource plus a service booking entry point.
Both regressions are pinned by tests, and both were checked by reverting each fix
in turn. The first attempt at the doctor-retention test passed even with the bug
restored, because the auto-select effect masked it; it was rewritten to use the
second doctor, where the bounce is observable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resource tabs now sit under the selected doctor and list only the resources that
doctor supervises, so moving between a doctor's own appointments and the devices
under them is one row of tabs rather than a flat list of everything.
The booking modal reads the doctor from the resource's supervisor instead of
asking again. The doctor↔resource relation is defined once, on the resource, and
repeating the question here would have made a second source of truth. A resource
whose supervisor was removed is blocked with a message pointing at the fix rather
than a silently disabled button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supervision now lives on the resource itself instead of being asked for again at
booking time, so one relation answers it everywhere.
The column is deliberately separate from the existing doctor_id bridge. That
bridge means "this resource IS this doctor" and isPerson() uses it to pin
capacity at 1; a supervised three-seat device must not become a person resource.
The FK is SET NULL rather than CASCADE because deleting a doctor should not take
the clinic's laser with it.
Required on create and non-clearable on update, enforced in the API where it can
give a Persian message. Ownership is checked through Clinic::hasDoctor so a
secretary cannot put their device under a doctor of another clinic; that returns
404, not 403, keeping foreign data invisible.
The 13 existing resources are backfilled deterministically: a practice resource
gets its own doctor, a clinic resource gets that clinic's first doctor. Both are
editable from the resource form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Add appointment" while a resource tab is active now opens a booking modal for
that resource: its own services, then a time, then the responsible doctor.
It reuses the booking engine that already existed (appointment-availability →
appointment-hold → appointment-confirm) rather than adding a second path. That
engine answers service-first and returns a resource assignment per slot, so the
modal keeps only the slots where the engine actually offered this resource and
pins that role to it on hold. Showing the other slots would let an operator pick
a time that can only come back as a 409.
The responsible doctor is required because every appointment has a doctor and
confirm will not run without one; the resource records which device the work
happens on.
The read-only "منابع" timeline under the schedule is removed along with its
component and hook, which had no other consumers. GET /api/v1/resources/timeline
is untouched on the backend and now has no client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Implement real-time validation for overlapping shifts in the ResourceWorkingHoursPanel.
- Remove the copy shift functionality to simplify the UI and prevent confusion.
- Introduce ResourceExceptionsCard to manage resource exceptions, including leave and maintenance.
- Update ClinicAppointmentSettingsPage to utilize new components and improve tab navigation for resource management.
- Add comprehensive validation tests for resource calendar to ensure overlapping shifts are correctly handled.
- Update API documentation to reflect new validation error messages and rules.
- Added new icons and improved error messaging for better user feedback.
- Refactored state management to include baseline comparison for dirty state detection.
- Introduced functionality to copy shifts across all days and reset to baseline.
- Updated UI layout for better responsiveness and usability.
- Enhanced tests to cover new features and ensure proper functionality.
refactor: update ClinicAppointmentSettingsPage to use PageHeader component
- Replaced BackButton with PageHeader for a more consistent header layout.
- Simplified the structure of the appointment settings page for better readability.
test: improve ResourceDetailPage tests for shift management
- Updated tests to reflect changes in shift display and error handling.
- Added tests for new features including the reset functionality and copying shifts.
style: add styles for weekly shift layout in ResourceWorkingHoursPanel
- Introduced new CSS classes for better layout and responsiveness of the weekly shift display.
- Ensured styles are consistent with the overall design system.
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table had two date columns for one date: "تاریخ" printed the raw
1405-05-13 string in Latin digits, and the column labelled "میلادی" ran the
same day through formatDate — which returns Jalali. One date, twice, under a
label that lied. It is now a single formatted Jalali column.
The closure form used a native <input type="date">: Gregorian, an English
mm/dd/yyyy placeholder in an RTL Persian panel, and a white box in dark mode
because a native control does not follow the theme. It is the shared Persian
picker now.
That picker turned out to be a div with an onClick — no role, no tab stop, no
accessible name, and its clear button was a span. Since every page that picks
a date goes through it, it gained role/tabIndex/Enter-Space, an ariaLabel
prop, and a real button for clear. The page passes labels for the year select
and both form fields, and the global topbar search got an aria-label, which
takes the runtime accessibility probe on this page to clean.
useHolidays now returns an error, so a failed request reads as an error
instead of an empty year — previously indistinguishable.
The page had no test file; it has eight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three admin endpoints shipped without anywhere to call them from, so the
person who is supposed to maintain the national calendar could only do it
with curl or a console command. That is not "the director can register the
year's holidays".
/admin/national-holidays is ROLE_ADMIN only and sits under the System group
in the admin nav. The year lives in the query string, so back and refresh
return to the year being edited.
Editing takes only the title: `date` is the unique key, so moving a holiday
is really deleting one and creating another, and the form says so rather than
silently creating a duplicate. The date field is the shared Jalali picker,
which speaks Gregorian, so the page converts before POSTing the jalali_date
the API expects — and shows the converted value under the field so the user
can see what will be stored.
Deleting warns that the day leaves every clinic's calendar, because it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.
Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.
only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.
Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anything that belongs to a service is defined on the service itself, so the
two tabs that managed selection groups and appointment segments come off the
service page.
Only the UI goes. SegmentTemplate is what makes a service occupy a room and a
device at the same time — it is the input to AppointmentPlanBuilder and the
reason the resource timeline has anything to draw — and a service without a
template already books through singleSegment(). Removing the model would
change booking; removing the tabs does not, which tests/Appointment confirms
at 314 green.
The active tab moved into the query string on the way past. That is what
makes the old ?tab=segments link land on the info tab instead of rendering
nothing, and it lets back and refresh return to the same tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A resource carries its own working hours and holidays in the resource-first
model, so it belongs on the same settings page as a doctor's schedule rather
than on a page of its own. The page now has a scope switch — doctors or
resources — with the per-item tab bar below it, and both scopes reuse the
panels that already existed: ScheduleSection for a doctor, the working-hours
and exceptions panels for a resource. The selection lives in the query
string, so back and refresh return to the same tab.
The screenshot of the finished tab caught two real defects, both fixed here:
Dates in the resource panels and the holidays page read as year 57932.
formatDate already multiplies seconds by 1000, and five call sites passed
`x * 1000` on top of it. This predates the tab — the code was inherited from
the old calendar page — but it was invisible until a two-week preview was put
on screen.
The working-hours panel still told the user their hours were intersected with
the branch's. Branches are gone; the shift is the only source now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The holiday model was already right — national holidays global, a per-tenant
override in both directions, per-doctor and per-resource exceptions — but
nothing could create a national holiday. The only writer was an import
command, so the calendar the whole product inherits from had no owner.
Three admin-only routes give it one. POST upserts, because `date` is unique
and re-sending a day should rename it rather than surface a raw database
error; PATCH takes only the title, because moving a date means a different
holiday. The system admin has no work environment, so the list endpoint now
returns the calendar with an empty `overrides` for that role instead of the
403 `pair()` would raise — the person who maintains the calendar has to be
able to read it.
Both holiday tabs — the doctor's and the resource's — now open with the
official calendar above their own exceptions, from one shared card rather
than two copies that would drift. Each row can be opted out of with a single
click, which is the existing holiday-override endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The appointments page only ever showed one doctor's row, but in the
resource-first model a single appointment can hold a room and a device at
the same time, and that — not the doctor's schedule — is what runs the
capacity out. An hour could look free on the doctor's lane while the only
alexandrite laser was already taken.
A third view, "منابع", draws one lane per resource for the selected day.
Blocks come from resource_occupancy rather than the appointment: that range
includes the device's setup and cleanup minutes and is the same range the
availability engine treats as busy. A multi-segment appointment therefore
shows up on every resource it holds, and each block links to the
appointment it belongs to.
GET /api/v1/resources/timeline keeps a fixed query count — one for
occupancy, one for shifts, one for the patient names — instead of one per
resource.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>