Commit Graph
100 Commits
Author SHA1 Message Date
hamedandClaude Opus 5 cc2e630c40 refactor(admin): stop asking the operator to re-pick the session's device
The appointment is booked on a device, and SessionExecutor already stamps that
device onto every area record when the session starts. The area form still
showed a device select — pre-filled, but a select — so the operator was asked
to make a decision that had already been made at booking time.

The inherited device is now read-only text. A "تغییر" button reveals the
select, for the one case that still needs it: the appointment was booked on the
wrong device. An appointment with no resource at all still gets the select
outright, with a hint saying why.

Also in this file: the parameter fields wrapped a label and a SearchableSelect
inside `.field`, which is itself the bordered input box — two nested boxes. They
are `.field-block` now, each control labelled by id. The note textarea gets
`cp-textarea`; it had no styling at all. Session and area counts go through
formatNumber, so the page no longer mixes Latin and Persian digits.

Adds the test file the page never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:00:29 +03:30
hamedandClaude Opus 5 072e10d0ba chore(graph): rebuild after staff sessions page redesign
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:51:44 +03:30
hamedandClaude Opus 5 8875b8c64a refactor(admin): rebuild the staff sessions page around the operator's question
The page an operator opens to see the day's work did not say who any of it was
for. TreatmentSession::toArray() carries no patient, so the list showed a
service name, a time, and a repeated 40-char button — the same three lines for
every row, with the finished work leading.

The endpoint now sends patient_name and resource_name. They are added in the
controller next to case_uuid/service_name rather than in toArray(), so patient
identity does not leak into every other consumer of that method.

The list is now a queue: unfinished work first, settled work (done, cancelled,
no-show) below it, each group counted. Every row leads with its time, names the
patient, and carries the service, device, session number and area progress on
one meta line. The whole row is the link, so the repeated button is gone.

Also fixes three things the redesign checklist calls out: Latin digits in the
session and area counts (formatNumber), a date repeated on every row of a page
whose title is "today", and a missing error state — a failed request rendered
as "no sessions today", which reads as an empty day rather than a broken one.

Adds the test file the page never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:51:26 +03:30
hamedandClaude Opus 5 1366a7f15c chore(graph): rebuild after staff queue and operator picker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:39:49 +03:30
hamedandClaude Opus 5 251b45d807 feat(admin): let the secretary pre-assign an operator when booking
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>
2026-08-07 12:39:35 +03:30
hamedandClaude Opus 5 38ea477429 fix(treatment): make the staff panel a queue instead of an assignment list
findTodayForStaff filtered on appointments.staff, and no booking path ever sets
that column. The result was a staff panel that was empty for every operator, in
every tenant, no matter how much work the day held.

Today's sessions now reach an operator three ways: the session they already
claimed (performedBy), the appointment a secretary pre-assigned to them, or
unclaimed work whose protocol names them. A protocol with no staff list means
everyone may perform it — the same "no rows is not a restriction" rule
ResourceServiceOffering already uses.

The query is also tenant-scoped, which the old one was not: it relied on
appointments.staff being a same-tenant row rather than saying so.

Verified against the dev database: the operator behind 09128726723 now gets
both of today's sessions on "لیزر توتال", which the old query returned none of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:34:01 +03:30
hamedandClaude Opus 5 f5902eeeeb chore(graph): rebuild after treatment case confirm fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:25:01 +03:30
hamedandClaude Opus 5 a331aab2b8 fix(treatment): open the treatment case when confirming from the panel
confirmWithPayments — the path behind POST /appointment/{uuid}/confirm, which
is how a secretary actually confirms — created the patient session but never
called TreatmentCaseStarter. Only onConfirmed did. So an appointment on a
service with an active protocol was confirmed and paid, and no treatment case
or sessions were ever created; the staff panel had nothing to list.

Every existing test in OpenCaseOnConfirmTest drove onConfirmed, which is why
the gap survived. Added one that drives confirmWithPayments; it fails without
the fix.

Also adds app:treatment:backfill-cases, mirroring
app:appointment:backfill-sessions: it reports confirmed appointments on a
protocol service that have no case, and with --fix replays the starter and
prints the exception the logger would otherwise keep to itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:24:29 +03:30
hamedandClaude Opus 5 b437390e06 chore(graph): rebuild after appointment modal redesign
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:08:48 +03:30
hamedandClaude Opus 5 103d913cd0 refactor(admin): restructure the new-appointment modal around its real steps
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>
2026-08-07 12:08:25 +03:30
hamedandClaude Opus 5 1f4f7e6927 refactor(admin): make the treatment plan tab usable and reversible
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>
2026-08-07 11:29:09 +03:30
hamedandClaude Opus 5 a63de2a52c fix(treatment): let the operator actually record an area
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>
2026-08-07 11:08:48 +03:30
hamedandClaude Opus 5 c7fdb92df4 feat(admin): practice domain settings, treatment case list and platform domain CRUD
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>
2026-08-06 21:51:21 +03:30
hamedandClaude Opus 5 d8aabe5d0a feat(admin): treatment plan tab, staff session screens and the device form editor
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>
2026-08-06 18:29:49 +03:30
hamedandClaude Opus 5 77eeefd5b4 feat(treatment): run a session from the staff panel, area by area
The operator opens the session, treats each body area on its own device and
records what that device was set to. Readings are validated against the resource
type's field schema, so a laser form and an RF form each enforce their own rules
without this code naming either.

Finishing is allowed with areas still open — the operator is standing in front of
a patient and must not be trapped by the software — but the count comes back so
the panel can warn. Session state mirrors onto the appointment (salon, then
completed) while its slot times are never rewritten: those are the reservation's
promise and the input to occupancy, whereas how long it actually took belongs to
the session. Overwriting them would destroy the comparison between the two.

Who performed it is recorded on the session rather than inferred from the
appointment's planned staff: when a colleague covers a sick operator, the medical
record must say who actually held the device.

Endpoints live under /api/v1/dashboard/staff because StaffRouteGuardSubscriber
closes everything else to staff-only users. Opening a second door through its
allowlist would put the access boundary in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:17:27 +03:30
hamedandClaude Opus 5 1cdd62979f feat(treatment): expose treatment cases, the unbooked queue and slot suggestions
Booking the next session stays a decision, not an automation: the system offers
free slots and the secretary picks one with the patient in front of them. Booking
automatically would fill the worst slot in the calendar — the one nobody wanted —
and produce a no-show.

A session whose due date has passed with nobody booking it surfaces in an
explicit queue instead of waiting silently for the patient to call. Suggestions
default to the resource the previous session ran on, since continuing a course on
the same device is both clinically steadier and one less choice to make; with no
previous booking the caller must name a resource rather than get an empty list.

Slot maths is reused from ResourceBookingSlotService; this only decides which
resource, from which day, and how far ahead to look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:03:16 +03:30
hamedandClaude Opus 5 9a95bc59d4 fix(appointment): make resource bookings independent of the doctor's calendar
Booking a device is not booking its doctor: the operator runs it and the doctor
only supervises. But bookAtomically locked the doctor row and isSlotTaken checked
overlap against the doctor alone, ignoring which resource was chosen, so a clinic
whose devices share one supervisor could not run two of them at once. Every
tenant in the database is in that position — clinic 2's six resources all point
at doctor 6.

Resource bookings now skip the doctor lock and carry no active_slot_key; their
guarantee comes from resource_occupancy, which understands capacity and seats.
Both direct paths write occupancy rows the way the hold engine already did, so
ResourceBookingSlotService stops being the only thing holding two sources of
truth together, and cancelling releases the seat.

Occupancy is bucketed in five-minute slices, which is coarser than a booking
time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and
collided with the next one starting at that same second, despite zero real
overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings
now snap both ends of their window down to the bucket grid — schedule-driven
slots are already aligned, so only manually entered times move.

The seat is claimed after persist because it needs the appointment id; losing
the race removes the appointment rather than leaving a booking with no device
behind it.

app:appointment:backfill-resource-occupancy gives existing resource-backed
appointments their missing occupancy and clears the doctor keys that no longer
mean anything. It reports conflicts between two old bookings instead of picking
a loser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:57:00 +03:30
hamedandClaude Opus 5 12c1d2cbf4 fix(appointment): fall back to the resource's supervising doctor on public booking
A laser device is not a doctor, so booking one from the public site sent
resource_uuid and no doctor_uuid and got back "doctor_uuid یا resource_uuid
الزامی است" — a message telling the caller to send something it had already
sent. The panel path had resolved this from ClinicResource.supervisor since it
was written; only the public path had not, and the field was defined but never
read there.

A resource with no supervisor now gets its own message pointing at the actual
fix, instead of the generic one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:31:21 +03:30
hamedandClaude Opus 5 252e20bfe9 feat(treatment): select treatment behaviour by practice domain, not by if-branch
Everything that differs between specialties as data is already stored as data.
What is left is behaviour — when a case opens, what happens once a session ends —
so it becomes a TreatmentWorkflow resolved through a tagged-service registry.
The booking path calls one collaborator and never names a specialty; adding
dentistry is a new class, not an edit to confirmation.

A clinic that has chosen no practice domain still gets working multi-session
courses: DefaultTreatmentWorkflow answers for null and for any code without a
dedicated implementation, keeping "unset means behave as today, not error".
LaserTreatmentWorkflow is deliberately empty beyond claiming `beauty` — it is the
seam where laser-specific behaviour will land without disturbing anyone else.

Session due dates are anchored to the previous session's actual finish, so a
patient who comes twenty days late shifts the rest of their course instead of
getting the next session while it can still do nothing. Only the next session is
recomputed; later ones keep their estimate because they are anchored to nothing
yet.

Attachment targets the first session without an appointment rather than the
first open one: a patient booking again mid-course was otherwise matched to the
session that already had a booking, and the second appointment went nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:23:51 +03:30
hamedandClaude Opus 5 9af763bfbe feat(resource): let a resource type declare the fields recorded against it
What an operator writes down after treating an area is decided by the device,
not by the service: a laser has energy, pulse and shot count, an RF unit has
something else. So the field list lives on the resource type, and adding a new
kind of device becomes a settings change rather than a migration.

One validator covers both directions — the schema when a manager saves it and
the values when an operator submits them. Splitting them would let a schema be
stored that no value can ever satisfy.

A value whose key is not in the schema is rejected rather than stored: silently
keeping it means the operator believes they recorded something that will never
be shown back to them. Option matching compares as strings so "18" and 18 are
one option, not two.

The migration seeds the laser type's three fields onto existing rows that have
none, so clinics already running laser devices do not start from an empty form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:05:48 +03:30
hamedandClaude Opus 5 6847a473d4 feat(treatment): open a treatment case with snapshotted areas and its sessions
Opening a case copies what must not move afterwards — the session count and the
list of body areas, each with its category name — because a treatment record is
a medical document and editing settings tomorrow must not rewrite what was done
yesterday. The areas are the leaf categories under the service's own category:
"توتال" contains bikini, leg and hand, and treatment happens on those three, not
on the grouping node above them. A category with no children is its own single
area, so "لیزر دست" gets one area rather than none.

Every session in the course is created up front so that "session 5 of 8" has
somewhere to live, but none of them is booked: creating eight real appointments
would lock eight months of slots for a patient who may not attend session three.

CategoryClosureResolver gains leaves(); the graph walk it already does is what
tells a leaf from a grouping node, so this belongs next to descendants() rather
than in a second traversal elsewhere.

TreatmentCase and TreatmentSession carry no money field, and must not: billing
lives on PatientSession, which is created when an appointment is confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:53:50 +03:30
hamedandClaude Opus 5 e2e3e6b43b feat(treatment): add treatment protocols, the multi-session course of a service
A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.

Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.

Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.

Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.

ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:36:04 +03:30
hamedandClaude Opus 5 85985b04a0 feat(practice-domain): add practice domains and let a clinic select one
A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.

Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.

Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:06:29 +03:30
hamed 1d43475724 feat(docs): add laser treatment plan and related ADRs for multi-session treatments 2026-08-06 15:23:06 +03:30
hamed 1a07dad17c feat(online-booking): add design document for online resource booking with deposit feature 2026-08-06 11:54:44 +03:30
hamed 0e7970d6e0 feat: replace checkboxes with Switch component for better UI consistency
- 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.
2026-08-05 16:09:46 +03:30
hamed f9bbdf1e7c feat(blog-review): enhance BlogReviewPage with detailed article retrieval and loading state 2026-08-04 20:38:09 +03:30
hamed 9d767a6c63 feat(dashboard): implement Jalali month start calculation and add related tests 2026-08-04 20:31:08 +03:30
hamed df8a43f8b2 feat(resource): remove ResourceCategoriesPanel and related functionality from ResourceDetailPage 2026-08-04 20:18:38 +03:30
hamed 2db4c3c4b0 feat(subscription): add resource quota management for subscription plans and update related components 2026-08-04 19:50:47 +03:30
hamed 3e7028d77a feat(subscription): implement resource quota management based on subscription plans 2026-08-04 19:38:49 +03:30
hamed 0dca245246 feat(invoice): synchronize invoice totals with patient session updates and add resync command 2026-08-04 13:12:24 +03:30
hamed 4653cd0e4a feat(subscription): return plan features for users without subscription permission 2026-08-04 12:54:12 +03:30
hamed 85a27812c7 feat(patient): implement record number pattern management
- 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.
2026-08-04 12:30:17 +03:30
hamed 9b8eef9598 feat(appointments): implement resource-based appointment scheduling and update related logic 2026-08-04 11:39:33 +03:30
hamed 810e9351a9 feat(invoice): implement recorder identity resolution for payments and update related tests 2026-08-04 11:19:07 +03:30
hamed 2c7b86d917 feat(resource): update permissions for resource access and enhance booking logic 2026-08-04 11:05:11 +03:30
hamed 429d7ed813 feat(appointments): enhance doctor and secretary views with scheduling context and resource management 2026-08-04 10:42:20 +03:30
hamed 1db1ee4cd8 test(schedule): add tests for booking mode changes and resource strategy removal 2026-08-04 10:35:49 +03:30
hamed 41b3109478 feat(appointments): add support for doctors without schedules and display relevant notice 2026-08-04 10:26:36 +03:30
hamed 654c1e8303 feat(search): enhance user search functionality to support Persian digits and user IDs 2026-08-04 09:53:31 +03:30
hamedandClaude Opus 5 cf0ca23f82 chore(graph): rebuild after the branch removal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:58:27 +03:30
hamedandClaude Opus 5 581a553516 refactor(resource): drop the branch domain from resources
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>
2026-08-03 14:57:47 +03:30
hamed 05bb4a4ade Add AST JSON representation for ResourceBookingSlotController.php
This commit introduces a new JSON file that contains the abstract syntax tree (AST) representation of the ResourceBookingSlotController.php. The AST includes nodes for classes, methods, and their relationships, as well as edges that describe imports and references between various components within the file. This addition will aid in understanding the structure and dependencies of the ResourceBookingSlotController.
2026-08-03 14:37:05 +03:30
hamed 4f69bc9044 feat: Implement resource booking functionality
- 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.
2026-08-03 14:34:23 +03:30
hamed 981261ed3a refactor(resource): remove ResourceBookingModal component and its tests 2026-08-03 14:34:17 +03:30
hamedandClaude Opus 5 28f853400b chore(graph): rebuild after offering sections
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:47:52 +03:30
hamedandClaude Opus 5 1543eddd9e feat(resource): expose each offering's service section
The resource booking modal groups services under their section, the way the
doctor's service booking does. The offering list had no section, so the list
could only be flat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:47:39 +03:30
hamedandClaude Opus 5 dc728acaaf chore(graph): rebuild after the back tab
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:08:44 +03:30
hamedandClaude Opus 5 c688f48583 fix(appointments): give the resource strip a way back to the doctor
Returning from a resource to the doctor's own booking meant clicking the doctor
tab in the row above, which nobody found. The resource strip now leads with a
"نوبت‌های خود پزشک" tab that clears the resource, so going in and coming back
happen in the same row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:08:31 +03:30
hamedandClaude Opus 5 8a53299cc9 chore(graph): rebuild after the resource day panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:58:30 +03:30
hamedandClaude Opus 5 732fbd462f fix(appointments): a resource is a view inside its doctor, and never slot-based
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>
2026-08-03 12:58:13 +03:30
hamedandClaude Opus 5 202be9a328 chore(graph): rebuild after the schedule-gated doctor tabs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:43:04 +03:30
hamedandClaude Opus 5 fd59eb57a3 feat(appointments): only doctors with a weekly schedule get a tab
my/clinic-doctors now reports has_schedule per doctor, and the appointments page
builds tabs from it. A doctor with no working days had a tab that could only ever
show an empty timeline.

The flag is resolved with one query for the whole list rather than one per
doctor. Clinic owners now read this authenticated endpoint too instead of the
public clinic doctor-list, which is where the flag lives; admin keeps the public
list and, with no flag present, hides nobody.

Also repairs fallout from making the resource supervisor mandatory: four test
classes build resources through their own helpers and were failing with 422. The
supervisorFor helper moved to ApiTestCase so all domains share one, rather than
copying it per suite. Full backend suite is green again (1306 tests) — the
previous commit only ran tests/Resource and missed this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:42:42 +03:30
hamedandClaude Opus 5 a63abc31be chore(graph): rebuild after supervisor-grouped resource tabs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:25:27 +03:30
hamedandClaude Opus 5 cadf18d07a feat(appointments): resource tabs follow their supervising doctor
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>
2026-08-03 12:25:10 +03:30
hamedandClaude Opus 5 4f4c396754 chore(graph): rebuild after resource supervisor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:15:55 +03:30
hamedandClaude Opus 5 ab4974d174 feat(resource): every resource is supervised by a doctor
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>
2026-08-03 12:15:42 +03:30
hamedandClaude Opus 5 b03ae95bf8 docs(prompt): record what actually happened in the resource-first task
The prompt was wrong three times and the file now says so up front: the
doctor-less appointment path was built and fully reverted, the getDoctor()
blast radius was overstated, and the booking engine it asked to build already
existed. Also records two tooling traps found on the way — phpstan runs at
level 5 here so it never checks nullability, and migrations:diff missed the
nullable change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:44:54 +03:30
hamedandClaude Opus 5 babfacfc73 chore(graph): rebuild after the resource booking modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:43:56 +03:30
hamedandClaude Opus 5 fd27ceef7d feat(appointments): book onto a resource from its tab, drop the read-only resource timeline
"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>
2026-08-03 11:43:33 +03:30
hamedandClaude Opus 5 993478fcc0 chore(graph): rebuild after the per-resource appointment tabs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:32:19 +03:30
hamedandClaude Opus 5 3e3a2482fc feat(appointments): per-resource tabs backed by a resource_uuid list filter
Resources now get their own tabs on the appointments page, alongside doctors.
An appointment on "Laser CO2" belongs to the device, not to whichever doctor
happens to stand behind it, so selecting a resource tab replaces the doctor
filter instead of stacking on top of it.

GET /api/v1/my/appointments gains an optional resource_uuid filter and returns
a `resource` object per row. The join is a leftJoin on purpose: appointments
created before the resource-first model have no resource and must not drop out
of the list.

The resource tab lives in the URL so Back and refresh restore the same view,
per the list-state rule in CLAUDE.md. The doctor tab is still useState; moving
it is a separate refactor and was left untouched.

Verified against the running app: filtering by a resource returns only its
appointments, a resource from another tenant returns an empty list (TenantFilter,
200 not 403), and legacy rows still list with resource: null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:31:37 +03:30
hamed 8509a04ae2 feat: refactor ResourceExceptionsPanel and related hooks; remove unused availability logic and simplify resource detail management 2026-08-03 10:36:13 +03:30
hamed 00c275d618 feat: refactor resource management by removing modal components and integrating functionality into tabs; add ResourceBlocksPanel for temporary deactivation of resources 2026-08-03 10:12:01 +03:30
hamed b2b36e3eec feat: add tests for ResourceServicesModal with validation and save functionality 2026-08-03 10:11:56 +03:30
hamed 82abd1bb52 feat: move "منابع" to main sidebar and update related components 2026-08-03 09:52:41 +03:30
hamed 17ce271b2c Add AST cache files for DurationCalculator, ResolvedServiceSpec, and ServiceItemCatalogCategoryTest
- Created JSON representation for DurationCalculator.php, including nodes and edges for methods and imports.
- Added JSON for ResolvedServiceSpec.php, detailing its structure and methods.
- Introduced JSON for ServiceItemCatalogCategoryTest.php, capturing test methods and their relationships.
2026-08-03 09:37:54 +03:30
hamed 348e1cf517 feat: enhance resource calendar validation and UI
- 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.
2026-08-03 09:37:22 +03:30
hamed c42679d98c feat: enhance ResourceWorkingHoursPanel with improved error handling and UI updates
- 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.
2026-08-03 09:24:50 +03:30
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
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>
2026-08-02 18:00:48 +03:30
hamedandClaude Opus 5 f06efe26c0 fix(holidays): honest dates and reachable fields on the holidays page
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>
2026-08-02 17:25:56 +03:30
hamedandClaude Opus 5 bd4347f9c5 feat(admin): a page for the official holiday calendar
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>
2026-08-02 17:14:23 +03:30
hamedandClaude Opus 5 5e87bbc18b feat(appointments): resources share the doctors' timeline
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>
2026-08-02 16:14:04 +03:30
hamedandClaude Opus 5 b8e8580867 refactor(services): drop the groups and segments tabs
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>
2026-08-02 15:56:49 +03:30
hamedandClaude Opus 5 9ba4c8d948 feat(settings): manage resource schedules beside the doctors'
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>
2026-08-02 15:49:01 +03:30
hamedandClaude Opus 5 f144401dd3 feat(holidays): one official calendar, inherited everywhere
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>
2026-08-02 15:39:48 +03:30
hamedandClaude Opus 5 dd284ec622 refactor(branch): remove the branch domain, keep the address
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>
2026-08-02 15:25:32 +03:30
hamedandClaude Opus 5 1c4f2a2451 chore(skill): make redesign-page a real UI/UX review harness
The skill's driver could not log in any more: its default credentials were
a user the scenario seeder wiped, so every command died on ERR_AUTH_005
before taking a single screenshot. Defaults now point at a user the seeder
actually creates, and the failure message says how to rebuild the users.

A page was also being judged on one screenshot. Dark mode and compact
density are real settings in this panel and mobile is where an RTL,
table-heavy admin breaks, so `variants` now captures all four and the theme
is written to the ui store rather than only stamped on the element — the
attribute alone is overwritten at hydrate. Narrow shots enable device
metrics, without which pointer:coarse media queries never fire and the
44px touch targets stay invisible.

Every shot now probes the live DOM for the things no grep can see:
horizontal overflow, nameless icon buttons, unlabelled fields, controls
under 32px. The static audit gained Gregorian dates, native date inputs,
icon buttons with no aria-label, and .seg without an on/active class.

`ds` prints the tokens and the shared components with their props, so a
redesign starts from what exists instead of inventing a second Modal.

Also corrected a stale claim: the suite has no pre-broken tests — it is
100 files / 660 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:27:23 +03:30
hamedandClaude Opus 5 444ebc897a feat(appointments): a resource-first view on the timeline
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>
2026-08-02 14:14:10 +03:30
hamedandClaude Opus 5 eeb9ae851a fix(settings): one menu list behind both settings navigations
The desktop sidebar kept its own copy of the item list, so the entries added
to the mobile settings menu — Resources, Categories, Branches, Holidays,
Price lists — never appeared in it. Adding a settings page meant editing two
files, and forgetting one was silent.

settingsMenu.ts is now the single source; the sidebar derives from it with
its two deliberate differences stated in code: no "manage doctor" entry, and
a disabled "security" row at the end. Labels follow the mobile list, which
uses the correct zero-width joiner spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:57:59 +03:30
hamedandClaude Opus 5 4380e64a5d fix(settings): every settings page renders the settings shell
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:49 +03:30
hamedandClaude Opus 5 f11514950e feat(services): pick a global category from the service page
The service page could not say which category a service belongs to, so the
containment edges defined in settings had nothing to match against.

A Categories tab now selects one — and only selects. Creating, renaming and
deleting stay in Settings > Categories: if every page could create one,
"whole body" would exist three times with three spellings and the
includes edge would stop catching anything.

PATCH /api/v1/service-item/{uuid} carries the choice as
catalog_category_uuid. Absent field leaves the current category alone, null
clears it, and a category from another environment is refused with 422 —
the uuid arrives in the request body where TenantFilter does not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:16:53 +03:30
hamedandClaude Opus 5 e08876f7b4 feat(resources): one tabbed page per resource
In the resource-first model a resource is the unit of capacity, so its
working hours, holidays, services, skills and categories belong to it — not
scattered across a list page's modals plus a separate calendar page.

/admin/resources/{uuid} now carries six tabs and the active tab lives in the
query string, so back and refresh land on the same view. The old
/calendar URL redirects to ?tab=hours instead of 404ing.

The skills and services modal bodies became panels the tab renders directly;
the modals are now thin wrappers, so the list page keeps working unchanged
and there is still one implementation of each editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:06:06 +03:30
hamedandClaude Opus 5 03d8d7d68a feat(catalog): manage global categories from settings
Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".

- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
  separate from `parent` because "hand" sits under both "whole body" and
  "upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
  from another environment is rejected explicitly since the uuid arrives in
  the request body where TenantFilter does not reach

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 12:58:26 +03:30
hamedandClaude Opus 5 f8e8a63ae8 Keep messenger_messages out of the migration that resets the database
doctrine:diff kept re-proposing CREATE TABLE messenger_messages, and with it in
place app:seed-scenarios --reset failed on a clean database: the doctrine
transport creates that table on boot, before migrations run, so the CREATE hit
a table that already existed. It is Symfony's table, not ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 12:23:36 +03:30
hamedandClaude Opus 5 1972fdd20f Document the resource-first model and retire the deleted tasks' checklists
docs/architecture/resource-first-model.md describes the shape: the three
entities, why an option is a ServiceItem rather than a fourth table, the
four-level resolution chain, the two conditions on the eligibility filter and
what each of them prevented, and why containment is a graph beside the display
tree rather than the tree itself.

docs/api/resource.md gains both offering endpoints with the response captured
from a real call, including a row where the price comes from the branch and one
where it comes from the resource — the two cases the *_source fields exist for.
docs/api/appointment.md documents resource_uuid, the doctor inference, and the
nullable resource/service_option in the response.

The checklists for tasks 9 to 14 keep their rows but open with a banner saying
the task was removed, when, by whose decision, and which commit to revert. They
are history now; deleting them would erase the record of work that shipped and
was then withdrawn.

Verified end to end: 1304 tests, slot-mode-frozen green, phpstan at 14, tsc
clean, 648 panel tests, and app:seed-scenarios --reset builds all three
environments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:05:33 +03:30
hamedandClaude Opus 5 26425bec31 Manage a resource's services from the panel
A "سرویس‌ها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.

Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.

Two backend adjustments came out of wiring it up:

- the offering filter in findEligible is now scoped to the requirement's
  resource type. Registering lasers for a service was making the room in the
  same plan ineligible and breaking the whole booking — "who performs this" is
  about the performing role, not about rooms and support resources. The seeder
  caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
  demo data exercises this model instead of leaving every resource empty.

Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.

Panel suite 648 green across 98 files, tsc clean, encore build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:57:55 +03:30
hamedandClaude Opus 5 a6acf3bfe2 Book for a resource, and manage the services a resource offers
POST /api/v1/appointment now accepts resource_uuid. When the resource is a
doctor the doctor is inferred from it, and the booking clinic is derived from
the resource's branch — sending clinic_uuid separately was only ever a way to
make the two disagree. The doctor-only path is untouched, which the public site
depends on since it sends nothing else.

Two guards before the booking is built. The resource must belong to the same
environment as the booking: it arrives as a uuid from the request body, so
TenantFilter does not cover it and without the check a patient could attach
another clinic's device to this clinic's appointment. And a resource that does
not offer the requested service is refused up front rather than discovered when
the patient turns up. That second check runs over the items the calculator
already validated rather than re-reading uuids, which is also why the
tenant-lookup inventory stays where it was.

GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list
returns the effective duration and price along with which level produced each,
so the panel can label an empty cell "30 minutes — service default" instead of
leaving the user guessing whether it is unset or zero. PUT replaces wholesale,
like the skills endpoint: a row absent from the body is a row the user removed,
and an empty string clears an override back to inheritance rather than setting
zero.

findEligible now also orders by category coverage — a device registered for
"foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that
categorised only some of its devices would otherwise lose the rest.

Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:20:41 +03:30
hamedandClaude Opus 5 0a2ba88808 Record which resource an appointment was booked for, and freeze its numbers
An appointment could say which services it was for but not which resource
performed them, so a booking on laser #2 was indistinguishable from one on
laser #1. Both columns are nullable: the appointments that already exist have
no resource and the migration must not break them.

resource_id is not a duplicate of resource_occupancy. Occupancy records what
was held and when — including rooms and devices held for a single segment. This
column records what the appointment is *for*, which is what the panel lists and
what the patient chose.

The option is kept separately from service_item because duration and price
resolve from the resource+service+option triple; without knowing the option,
the stored number cannot be explained later.

Tests: the resource and option survive a round-trip, stored minutes come from
the resolver rather than the service default (15 where the service says 30),
raising the tariff afterwards leaves the earlier snapshot at 8M, and an
appointment with no resource still serialises with nulls instead of failing.

Suite 1290 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:48:45 +03:30
hamedandClaude Opus 5 6d7c54508c Let categories contain other categories, and share them with resources
Two gaps against the spec. Resources could not be categorised at all — only
services carried a catalog category — so "this device is for hands and feet"
was unsayable. And CatalogCategory::$parent is a tree built for menu ordering:
one parent per category. Laser areas overlap, so "hand" belongs under both
"whole body" and "upper limb" at once, which a tree cannot express.

Containment is therefore a separate directed acyclic graph
(catalog_category_includes) sitting beside the display hierarchy, and resources
join the existing clinic-wide categories through a many-to-many rather than
growing a parallel list of their own.

CategoryClosureResolver walks it transitively: whole body includes lower body
includes foot, so whole body includes foot without anyone writing that pair
down. The walk reads every edge of the environment in one query and traverses
in memory — a query per level would tie round-trips to graph depth. The visited
set doubles as the cycle guard, so even data that already contains a loop
cannot hang the traversal, and assertNoCycle refuses to create one.

Selection now rejects picking an area together with a category that contains
it: "whole body laser" and "hand laser" in one appointment is a 422 with a
Persian message naming both. This replaces hand-written incompatible_with pairs
for the area case — defined once on the category instead of per item pair —
while that relation stays for incompatibilities that have nothing to do with
areas.

Nine tests, including the two-parents case a tree could not hold, the cycle
refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest
caught the new edge entity as unclassified; it is registered as an aggregate
child of the parent category, which is what the constructor already enforces.

Suite 1286 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:43:12 +03:30
hamedandClaude Opus 5 021d9f82a2 Pick candidate resources by whether they actually offer the service
findEligible matched on address, type and skills, so two devices of the same
type were interchangeable even when only one of them performed the service.
It now also consults the offering table.

The filter is conditional on purpose: it only applies once the clinic has
registered at least one resource for that service. Applying it unconditionally
would leave every environment that has not filled the links in yet without a
single free slot overnight — a silent outage caused by a feature they never
opted into. When rows do exist but all are inactive the result is empty, which
is the honest answer: nobody performs this right now.

The service comes from the segment template rather than the root service. One
appointment's plan can carry segments from several items, and "who can do this"
is a per-item question.

Five tests: the filter picking one of two identical devices, the no-rows
passthrough, the all-inactive empty, the no-service-argument path still
untouched, and the filter stacking with the skill filter.

Suite 1277 green — including the 27 existing plan and availability tests, which
is what proves the backward-compatible path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:26:24 +03:30
hamedandClaude Opus 5 a70a98769b Resolve a service's duration and price from the resource that performs it
The chain the spec asks for, plus the branch level that already has data:
resource+option, resource+service, branch override, then the item's own value.

Duration and price resolve independently. If they resolved together the first
override would silently swallow the other value — a resource that only differs
in how long it takes would also drop the branch's tariff.

Each resolved value carries where it came from. Without that, the panel cannot
label a number "from the branch" or "service default", and "why this number?"
becomes a four-table investigation.

Two rules worth stating: null means inherit while zero is a real value, so a
free service keeps its zero instead of inheriting the parent's price; and an
inactive offering is skipped whole, since "this resource does not perform this
right now" is not the same as "I have no opinion on the numbers".

The parent service is passed in rather than looked up from the item's group.
The booking flow already holds both, and a reverse query would be a second way
to answer a question that already has an answer in hand.

Eight tests: one per level with the other levels populated so the winner is
provable, plus independent resolution, the inactive skip, zero, and resolving
the service itself without a parent.

Suite 1272 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:17:29 +03:30
hamedandClaude Opus 5 826b940c00 Add the resource↔service link that decides who offers what, and for how much
Until now a resource was picked by type and skill alone, so two devices of the
same type were indistinguishable even when only one of them performed the
service — and there was nowhere to say that this doctor takes 30 minutes for a
filler while that one takes 45.

ResourceServiceOffering is that link: resource ↔ service item, with an optional
duration, an optional price and an active flag. Because a "service option" here
is itself a ServiceItem inside an ItemGroup, one table covers both levels the
spec asks for — a row against the parent item is "resource + service", a row
against a member item is "resource + option". A third table would have meant
two sources of truth for one concept and a rewrite of every path that already
speaks ServiceItem.

It is an aggregate child of ClinicResource, like ResourceSkill: no tenant
columns of its own, since the resource already carries the pair and a copy is
just something that can drift. The constructor refuses a resource and a service
from different environments — TenantFilter does not cover that case, as both
uuids arrive from the request body and the filter does not apply to aggregate
children.

null means inherit, not zero: an explicit zero is a duration that does not
exist, while null means this resource has nothing to say and the resolver
should look one level up. Zero and negative values are rejected outright.

Tests cover the pair being stored, the duplicate pair hitting the unique
constraint, the cross-environment guard, null-means-inherit, one service across
two devices with different numbers, and deactivating without losing them.

Suite 1264 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:01:18 +03:30
hamedandClaude Opus 5 8280aa7578 Remove the policy, package, course, cancellation and event subsystems
The product owner scoped the booking model down to resources, services and
service options; everything else goes. Deleted, with their tables, tests, panel
pages and API routes:

  src/Policy      27 files   policy engine, simulation, templates
  src/Package     13 files   packages and the credit ledger
  src/Course      14 files   treatment protocols and courses
  src/Cancellation + src/Waitlist  19 files  penalties, no-shows, waitlist
  src/Report + src/Shared/Event    events outbox and the two reports

The surviving code had hooks into all of them, so this is not only deletion:

- AppointmentPlanBuilder loses the timing and resource policy passes, and the
  requirement-from-policy builder that had no other caller (449 → 379 lines)
- PricingEngine loses policy discounts and package coverage; PriceQuote loses
  its package fields and quote() its patient argument, which existed only to
  find a usable package
- BookingService no longer consumes credit, unlinks course sessions or emits
  events; HoldService and ResourceBlockController stop emitting too
- ServiceSelectionValidator keeps groups and relations, drops the policy layer
- the appointment loses its course_session link

Cancelling an appointment still works and still records who and why — it moves
through PATCH /api/v1/appointment/{uuid}/status. What it no longer does is
compute a penalty, so the panel dialog is a plain confirm now. That is the
visible cost of this change and it is intended, not an oversight.

The drop migration uses DROP TABLE IF EXISTS with foreign-key checks off: a
half-applied earlier run left some tables already gone, and a migration that
only works from a pristine schema is a trap on any environment that was
interrupted. messenger_messages is deliberately left alone — it is Symfony's,
not ours.

Suite green at 1258 tests, slot-mode-frozen green, phpstan back to its 14-error
baseline after removing two now-unused members it caught, tsc clean, and
app:seed-scenarios still builds all three environments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:51:41 +03:30
hamed c4f1f25c80 Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
2026-08-01 20:50:47 +03:30
hamed 9486721fa3 Remove unused pages: PolicySimulationPage, ResourceUtilizationPage, TreatmentCoursePage, WaitlistPage, and their associated tests 2026-08-01 20:25:42 +03:30
hamed 65d5831c64 Remove ReportTest and WaitlistTest files as part of codebase cleanup 2026-08-01 20:16:41 +03:30
hamedandClaude Opus 5 4711ba0af7 docs: prompt and checklist for the resource-first booking model
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:13:55 +03:30
hamedandClaude Opus 5 fc6b865c15 feat(seed): make the seeded environments exercise the new booking engine
The first version of the seeder only filled the old skeleton: doctors, clinics,
services, appointments written straight to the table. None of the sixteen tasks
under docs/new_feture had any data, so nothing they built could be tried.

BookingEngineSeeder now seeds, per environment:

- catalog v2: a category tree, an item group with a 1..2 selection range, an
  incompatible pair, and a per-branch price override
- resources: a skill with levels, a resource pool with priorities, and a
  maintenance window next week
- a three-segment plan on the flagship service — numbing (room exclusive), wait
  (room passive, nobody else held), laser (room + device) — which is the whole
  point of the plan model and cannot be seen with single-segment services
- an active price list, and a price snapshot per booked appointment
- six policies, one per category, each with a condition and an effect
- a package with a consumed session in the ledger, and a treatment protocol with
  per-session parameters plus an active course carrying its six sessions
- a general and a per-service cancellation policy, no-show records, waitlist
  entries

Two appointments per environment are booked through the real path —
AppointmentPlanBuilder, AvailabilityEngine, HoldService, BookingService — so
segments, resource occupancy and the domain-event outbox are populated by the
code that will run in production rather than by INSERTs.

Two defects in the seeding surfaced and are fixed here:

- SegmentRequirement is the owning side, so persisting one leaves the template's
  in-memory collection empty. The plan built later in the same process saw
  segments that needed nothing, and the bookings occupied the device but never
  the room — while the same service was correct over HTTP, where the entity is
  read fresh. The collection is now kept in step.
- passing the service as its own selected item produced a different plan than
  the booking flow builds.

A course with no sessions also reported "everything is scheduled"; sessions are
now created from the protocol steps.

Verified over HTTP: segments come back as 5/30/20, utilization reports 110
minutes on the room and 90 on the laser, the course suggests session 1 with
three slots, and the six policies list one per category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:28:26 +03:30