Compare commits

297 Commits
Author SHA1 Message Date
hamed 68b8b05630 feat: add landing page templates and registration modal
- Implemented header and hero section in _header.html.twig and _hero_art.html.twig.
- Created a pre-registration modal in _reg_modal.html.twig with form fields and validation.
- Added page scripts for dynamic behavior and interaction in _page_scripts.html.twig.
- Developed landing page structure in landing.html.twig, integrating header, footer, and modal.
- Introduced tests for landing page rendering and registry validation in LandingPageTest.php and LandingRegistryTest.php.
2026-08-10 14:38:27 +03:30
hamed 231ce793bc Add user guides for clinic management roles in Persian
- Create README.md for user guides covering roles: independent doctor, clinic owner, clinic manager, secretary, and staff.
- Add detailed step-by-step guides for setting up and managing clinics for clinic managers and clinic owners.
- Include instructions for independent doctors on setting up their practice.
- Provide a comprehensive guide for secretaries on daily operations and patient management.
- Introduce staff guide for nurses and technicians on session management.
2026-08-10 14:10:26 +03:30
hamed bb2dbc3371 fix(appointment): ensure appointment details are always fetched from the server to avoid incorrect pricing 2026-08-10 12:21:28 +03:30
hamed 7073377122 feat(billing): add command and tests for backfilling missing invoices for paid sessions 2026-08-10 12:08:58 +03:30
hamed 3365a0427e feat: implement useOverlayDismiss hook for consistent modal dismissal behavior 2026-08-10 11:27:16 +03:30
hamed 8a18457751 feat(billing): ensure finalized invoice for confirmed appointments with payments 2026-08-10 11:10:37 +03:30
hamed 505ab412a3 feat(appointment): add percentage shortcuts for patient share in payment rows 2026-08-10 10:38:56 +03:30
hamed a8699065b8 fix(tour): adjust close button positioning for driver popover in RTL layout 2026-08-10 10:30:46 +03:30
hamed ecdefa3c24 feat(tour): add onboarding tours for various admin pages
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
2026-08-10 10:10:20 +03:30
hamed 20c8eaaad9 feat: add TourProgressController and related entities for user tour progress tracking
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
2026-08-10 09:34:14 +03:30
hamed 7716b40f6a feat: implement tax calculations for subscription and SMS wallet payments
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials.
- Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges.
- Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input.
- Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet.
- Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types.
- Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled.
- Updated frontend components to display tax information appropriately during payment processes.
2026-08-09 16:51:22 +03:30
hamed 2471c90cbb feat(payment): unify payment callback endpoint for all gateways and types 2026-08-09 16:02:48 +03:30
hamed a6a965a2aa feat: add admin subscription granting feature
- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
2026-08-09 13:43:30 +03:30
hamed 60ccd5cc1d feat: implement OTP login flow and enhance role-based access control
- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment.
- Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions.
- Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience.
- Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking.
- Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements.
- Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
2026-08-09 12:41:11 +03:30
hamed cfeb447645 feat: add PublicResourceBookingController and PublicResourceBookingService for public booking functionality
- Implemented PublicResourceBookingController to handle public resource booking requests.
- Added methods for retrieving bookable resources, available slots, and month availability.
- Created PublicResourceBookingService to manage public resource offerings and service visibility.
- Developed tests for public resource booking to ensure correct functionality and error handling.
2026-08-09 10:45:51 +03:30
hamed cd793489ef Add AST cache for BlogIdentifierResolutionTest.php with detailed node and edge relationships 2026-08-09 09:14:44 +03:30
hamed 3ffab2bbd0 feat(stepper): implement multi-step wizard for appointment confirmation process 2026-08-09 09:12:00 +03:30
hamed 948f59827a feat(appointments): implement multi-step wizard for appointment booking modal 2026-08-09 08:53:55 +03:30
hamed b49abee52f refactor: update pricing input handling to use PriceInput component
- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility.
- Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting.
- Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components.
- Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling.
- Changed labels from "نمایش در نوبت‌دهی" to "نمایش در نوبت‌دهی آنلاین" for clarity.
2026-08-09 08:38:23 +03:30
hamed e48ab9a974 feat(settings): implement grouped menu structure and update related components 2026-08-09 07:56:48 +03:30
hamed 89a1428ca0 feat(tests): update topic slug generation for blog identifier resolution tests 2026-08-09 07:22:31 +03:30
hamed dcf9285467 feat(blog): enhance blog identifier resolution to support multiple identifiers and improve URL handling 2026-08-09 07:14:16 +03:30
hamedandClaude Opus 5 f6ef086589 chore(graph): rebuild after multi-specialty doctor search
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:45:03 +03:30
hamedandClaude Opus 5 2da5b5188c feat(doctors): search every specialty a doctor has, and expose the tree
`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.

`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".

Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.

toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.

Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.

Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:44:32 +03:30
hamedandClaude Opus 5 08a344c99d chore(graph): rebuild after representation edit policy
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:51:01 +03:30
hamedandClaude Opus 5 fb1cb20c11 feat(representation): let registering reps edit their doctors and clinics
A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:50:17 +03:30
hamed d74a351e5a feat: enhance search functionality to include parent names and related fields across categories 2026-08-08 14:57:36 +03:30
hamed 35181cd715 feat: implement OptionsInput component for handling select field options 2026-08-08 14:33:49 +03:30
hamed 3862a91fd1 Refactor code structure for improved readability and maintainability 2026-08-08 14:25:27 +03:30
hamed 47323daa27 feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
2026-08-08 11:40:17 +03:30
hamed 934405c42d feat: Implement permission gate for appointment and billing controllers
- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
2026-08-08 10:27:13 +03:30
hamed c452150a83 refactor: remove ResourceBookingPage and related components; update references in AppointmentsPage and ScheduleSection 2026-08-08 07:19:48 +03:30
hamed b699476305 feat(appointment): derive service section from service item when missing in appointment 2026-08-08 07:14:40 +03:30
hamed da07e3ad9c fix(tests): reset EntityManager in ApiLeastPrivilegeTest to prevent stale references 2026-08-08 06:47:31 +03:30
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- 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.
2026-08-07 21:13:38 +03:30
hamed a4a24c51af refactor(guidelines): update guidelines and add mandatory Grill section for all skills 2026-08-07 20:03:49 +03:30
hamed 9b9a7500e2 chore(graph): rebuild after secretary modal split 2026-08-07 19:55:55 +03:30
hamedandClaude Opus 5 7c3407b0b3 refactor(admin): split the secretary form from its permissions into two modals
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>
2026-08-07 19:55:32 +03:30
hamed a6eee1ce9d chore(graph): rebuild after clinic-doctor read gate symmetry 2026-08-07 19:31:14 +03:30
hamedandClaude Opus 5 d6de746938 fix(permissions): apply the patient and tag read gates to invited clinic doctors
PatientController::resolveScope and TenantTagController::guardTagView only ever
checked the secretary, while every write in both controllers already ran through
both checkers. So an invited clinic doctor with patients.view off got 200 with an
empty list where a secretary got 403 — one permission, two behaviours. No data
was exposed either way; tenant scoping emptied the result.

The fix is not canOrNonMember. That collapses two different situations: a
membership row switched to active=false means the collaboration ended, and
ClinicDoctorPermission::can() returns false for everything in that case too.
Routing it through the permission gate turned the existing 404 on a single record
into a 403, which confirms the record exists to someone who just lost access.
ClinicRecordAccessTest caught it.

isActiveMemberDenied() answers the narrower question — active member, permission
off — and leaves a deactivated row to the data scope, which closes it with a 404
and discloses nothing. A test now pins that distinction so it cannot be collapsed
again.

Tags keep the tags.view OR patients.view rule, now for both roles.

Verified live in three states: active with both off 403/403, deactivated not 403,
active with patients.view on 200/200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:30:52 +03:30
hamed 6a6852379d chore(graph): rebuild after addresses permission fix 2026-08-07 19:16:26 +03:30
hamedandClaude Opus 5 294ca19a46 fix(permissions): make the addresses resource real instead of decorative
A full role-by-role sweep (9 roles x 18 endpoints against the running app) showed
the addresses toggles in the owner's permission form controlled nothing. Grep
confirms it: no gate anywhere referenced 'addresses'. The panel's address list was
gated on appointment_settings.view instead — the same borrowed-permission pattern
already fixed for resources and treatment.

GET /api/v1/addresses now gates on addresses.view.

The resource drops to view-only. Creating, updating and deleting an address in
ClinicController is explicitly owner-or-admin
($clinic->getUser()->getId() !== $user->getId()), so those three actions could
never be delegated to a secretary or an invited doctor no matter what the form
said. Both role defaults narrow to ['view' => true] to match, and stored JSON
keeps its old keys harmlessly since merge only reads registry keys.

This widens secretary access: addresses.view defaults to true while
appointment_settings.view defaults to false, so secretaries who could not list
addresses now can. That is deliberate and costs no confidentiality — the same
addresses are already served anonymously from
GET /api/v1/clinic/{uuid}/addresses, which is whitelisted in security.yaml.

Verified live in three states: default 200, addresses.view off 403, and
addresses off with appointment_settings on still 403, proving the borrow is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:16:02 +03:30
hamed be5cdf8af6 chore(graph): rebuild after service catalog permission gate 2026-08-07 18:52:47 +03:30
hamedandClaude Opus 5 073a68bcc2 fix(admin): split the catalog page buttons onto per-action services permissions
The backend gate is now per-action, so a single canUpdate driving add, edit and
delete would show buttons the server answers 403 to. Each button now checks its
own action, and the includes modal takes canCreate/canDelete so its add select
and per-edge remove button follow the same split.

ServiceCategoryTab is deliberately left alone: its save patches
/api/v1/service-item/{uuid}, which is ClinicServiceController and already gated on
services.update, so canEdit was already the right permission. Only its read of the
category tree moved behind services.view, and the page it lives on already
requires that.

The page test now drives a configurable can(), covering view-only, create-only,
update-only and delete-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:52:25 +03:30
hamedandClaude Opus 5 52c45443c5 fix(security): gate every ServiceCatalogController route on the services permission
The controller carried only IS_AUTHENTICATED_FULLY on the class and none of its
15 routes checked a permission. A secretary whose owner had turned `services`
fully off could still create, rename and delete service categories, build item
groups, replace group members, and rewrite service relations and per-branch
overrides.

Scope is intra-tenant privilege escalation, not IDOR: owned() and requireItem()
already resolve every uuid against the caller's active environment, so no data
crossed tenants.

Gating is per-action (view/create/update/delete) and reuses denyServices() from
ClinicServiceController in the same domain, so a secretary with `update` cannot
create or delete. The call is the first statement in every action, before
requireCategory/requireItem — placed after, an unknown uuid would answer 404 and
leak whether the record exists.

An earlier note claimed these endpoints were consumed by the booking flow and so
could not be closed. That was wrong. service-selection/validate, the group routes
and the relation routes have no consumer in any of the three API clients, and the
sibling controller already puts every service read behind services.view — the
booking modal reads service-items through it — so any flow needing services
already needed the permission.

The docs claimed appointment_settings.* for the includes routes, which was never
enforced either; corrected to services.*.

The test loops the whole route list rather than sampling, and a guard asserts the
count of #[Route( equals the count of denyServices( so a future ungated route
fails here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:46:16 +03:30
hamed be93e15bad chore(graph): rebuild after subscription doc correction 2026-08-07 18:39:05 +03:30
hamedandClaude Opus 5 1d10f8c907 docs(api): correct the subscription/my enforcement claim
The previous note said GET /api/v1/subscription/my has no gate because it
returns 200 with every permission off. That was wrong. It gates deliberately
with a degraded payload instead of a 403: without subscription.view the
response drops the active subscription and used_trial, and effective_plan
keeps only features, max_secretaries and max_resources — no plan identity,
no billing. Verified against the running app both ways.

The 403 it does not throw is the point: FeatureGate and useSubscription need
capability flags on every page, so a 403 would break the whole panel.

Also adds .claude/prompt/service-catalog-permission-gate.md for the one real
gap, with the per-route analysis that was previously deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:38:36 +03:30
hamed ccd869e442 chore(graph): rebuild after permission docs 2026-08-07 18:21:11 +03:30
hamedandClaude Opus 5 0a22cf98fb docs(api): document the permission registry and correct two enforcement claims
clinic.md's default envelope is regenerated from the running app, so it now shows
all 17 resources instead of 13, including services with its full create/delete
actions. Both role docs point at permission.md for the shared registry and spell
out the merge rule that makes new resources work on existing rows: deleting a key
means "take the default", not "deny" — denying requires an explicit false.

A systematic sweep over every gated route with all permissions off found two
places where the docs claimed enforcement that does not exist:
- GET /api/v1/subscription/my returns 200 with every permission off. Only trial
  is gated.
- ServiceCatalogController has no gate at all.

Both are pre-existing and both are left as-is rather than half-fixed: their
endpoints are also consumed by the booking and subscription flows, where a hard
gate would break secretaries who legitimately need them. The docs now say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:20:49 +03:30
hamed a0fd85d910 chore(graph): rebuild after dynamic permission UIs 2026-08-07 18:11:25 +03:30
hamedandClaude Opus 5 ddd5f8f75a feat(permissions): render both permission forms from the catalog, fix borrowed gates
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>
2026-08-07 18:11:05 +03:30
hamed dc40651308 chore(graph): rebuild after permission catalog endpoint 2026-08-07 17:40:11 +03:30
hamedandClaude Opus 5 5211b34d0e feat(permissions): expose the registry over GET /api/v1/permission-catalog
Both permission forms in the admin panel can now render from the backend
registry instead of their own hardcoded lists. Resources come back as an array
so display order is part of the contract, each carrying its Persian label, its
actions, and the clinic_only flag that used to live in the frontend.

contextPermissions() normalizes the no-row branch through the registry too, so
a doctor whose permission row was never provisioned sees the same shape as one
who has it.

Two existing assertions compared the API response against DEFAULT_PERMISSIONS
by identity. The values are unchanged; only key order moved to the registry's,
so both now compare through PermissionCatalog::merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:39:45 +03:30
hamed aff7b7fd4a chore(graph): rebuild after permission registry 2026-08-07 17:35:08 +03:30
hamedandClaude Opus 5 1d1efd7a85 feat(permissions): single registry for secretary and clinic-doctor permissions
The list of permissionable resources was duplicated in six places that had
already diverged: both permission entities, three admin UI files and the
SecretaryPermissions TypeScript interface. Adding a resource meant editing all
of them, so new pages borrowed an unrelated resource instead — five resource
pages sat on appointment_settings.view and treatment-cases on appointments.view.

PermissionCatalog is now the only place that says which resources and actions
exist. Each entity keeps its own DEFAULT_PERMISSIONS, but as role policy only;
a test asserts those defaults never name a resource the registry doesn't have.

getPermissions() merges the stored JSON over the role defaults, so a resource
added to the registry later resolves to the role default instead of silently
false for every existing row. Explicitly stored values are never overwritten,
and no data migration is needed.

Two asymmetries fixed along the way:
- ClinicDoctorPermission validated writes against its own DEFAULT_PERMISSIONS,
  so services.create/delete could never be stored for an invited doctor.
- DoctorSecretary had no validation at all and would store any key, and it only
  read $patch['resources'] — the admin SecretariesPage sends a flat map, so its
  permission edit silently did nothing. Both entities now accept either shape
  and filter through the registry.

New resources 'resources' and 'treatment' are registered with defaults chosen to
preserve today's effective access, since both pages are currently gated on a
borrowed resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:32:28 +03:30
hamed 0360a5e46f Refactor code structure for improved readability and maintainability 2026-08-07 17:16:10 +03:30
hamedandClaude Opus 5 01100b90ad chore(graph): rebuild after collapsible session cards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:12:38 +03:30
hamedandClaude Opus 5 7a6bc313d2 refactor(admin): collapse session cards in the course detail
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>
2026-08-07 17:12:28 +03:30
hamedandClaude Opus 5 bc8003562a chore(graph): rebuild after treatment card view
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:05:09 +03:30
hamedandClaude Opus 5 f61b79b67e refactor(admin): course cards with a detail view, and fill the banner's next date
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>
2026-08-07 17:04:55 +03:30
hamedandClaude Opus 5 8672608696 feat(admin): let the booking date be changed inside the modal
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>
2026-08-07 16:57:17 +03:30
hamedandClaude Opus 5 e385056f09 chore(graph): rebuild after treatment tab rename and prefill
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:43:49 +03:30
hamedandClaude Opus 5 288919339e feat(admin): name the tab for what it shows, prefill the patient, paginate and search
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>
2026-08-07 16:43:34 +03:30
hamedandClaude Opus 5 02fdfd13cb chore(graph): rebuild after patient treatment plan tab
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:28:27 +03:30
hamedandClaude Opus 5 c4099f0f63 feat(admin): default the booking staff from the service's protocol, and call it پرسنل
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>
2026-08-07 16:28:13 +03:30
hamedandClaude Opus 5 de52d668c0 feat(admin): 'next appointments' tab in the patient file
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>
2026-08-07 16:21:31 +03:30
hamedandClaude Opus 5 fc50ac4b3b feat(treatment): endpoint for a course's calendar and its recorded work
GET /api/v1/treatment-case/{uuid}/plan returns every session with a date and an
is_estimate flag, plus each session's area records — the device readings a staff
member actually logged. Until now nothing exposed either: due_at existed only
for the next session, and TreatmentCase::toArray() serialised sessions without
their areas, so 'what was done' was unreachable outside the staff panel.

Kept separate from GET /treatment-case/{uuid}; that response feeds the edit
modal, which needs neither the calendar nor the areas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:12:55 +03:30
hamedandClaude Opus 5 50d82279d6 feat(treatment): project a whole course's calendar without storing it
TreatmentScheduler deliberately writes only the next session's due_at, because a
date not yet anchored to anything real is a false claim about the future and has
to be rewritten every time a patient runs late. But the panel still needs to
show the whole course.

TreatmentPlanProjector builds that chain at display time and writes nothing.
Each date carries is_estimate so a projection is never mistaken for a fact. A
session's anchor is, in order: when it was finished, when its appointment is, or
its written due_at; a case with none falls back to when it was opened, so a
course that has not been booked yet still shows dates instead of blanks.

Read and write stay in separate classes — mixing them risks storing an estimate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:08:19 +03:30
hamedandClaude Opus 5 250e0b0813 feat(treatment): filter treatment cases by patient record
The list could be narrowed by status, search and open-date, but not by patient
— so a patient's own file had no way to ask which courses belong to them.
`?record=` adds that bound.

patientRecord is joined once and shared with the search branch; joining it twice
under the same alias is a DQL error, and search already needed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:05:35 +03:30
hamedandClaude Opus 5 fbaf98a1f5 chore(graph): rebuild after explicit session booking
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:44:36 +03:30
hamedandClaude Opus 5 c7a3b88b32 feat(treatment): bind an appointment to a chosen session, and free it on cancel
Two holes in how a course's later appointments were made.

The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)

POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.

Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.

The system still never books the next appointment by itself — it only suggests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:44:22 +03:30
hamedandClaude Opus 5 d857242145 chore(graph): rebuild after treatment cases table view
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:13:42 +03:30
hamedandClaude Opus 5 d3f7812d15 feat(admin): table/card views for treatment cases, matching the patients list
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>
2026-08-07 15:13:27 +03:30
hamedandClaude Opus 5 9cabb28355 chore(graph): rebuild after per-case operator assignment
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:55:16 +03:30
hamedandClaude Opus 5 b78f7311cf feat(treatment): per-case operators, shown on the list and searchable
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>
2026-08-07 14:55:01 +03:30
hamedandClaude Opus 5 12c0d57e1a chore(graph): rebuild after removing services from the staff dashboard
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:38:35 +03:30
hamedandClaude Opus 5 e43e8ec93e refactor(dashboard): drop the services list from the staff dashboard
The assigned-services card and its stat card are gone. The staff role has one
job — today's sessions — and with /admin/my-services already removed, a list of
services the operator cannot act on was the last thing on the page competing
with the work itself.

The remaining stat card no longer stretches the page: `.stat-grid` is built for
four cards and collapses to one full-width column with a single child, so the
staff grid is capped.

The endpoint still returns `services` and `stats.services`; the field is
documented and cheap, and nothing else needed to change to stop rendering it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:38:21 +03:30
hamedandClaude Opus 5 93c11139fd chore(graph): rebuild after area skip note and reopen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:28:48 +03:30
hamedandClaude Opus 5 a182b05e1f feat(treatment): skip reasons and reopening for session areas
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>
2026-08-07 14:28:30 +03:30
hamedandClaude Opus 5 a6180bc7e7 chore(graph): rebuild after staff dashboard fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:16:15 +03:30
hamedandClaude Opus 5 5b3e80f92a fix(dashboard): make the staff dashboard show the operator's actual work
Every number on it was zero. "نوبت‌های امروز من" counted appointments where
appointments.staff_id matches — a column no booking path fills by default, and
which is NULL on every row in the database. "سرویس‌های من" read only direct
service assignment, so an operator whose whole job comes from a treatment
protocol was told they had no services. The landing page of the only role that
has one data page said, in effect, that they had nothing to do — while they had
two sessions booked that day.

Today's work now comes from TreatmentSessionRepository::findTodayForStaff, the
same queue rule the sessions page uses, so there is one definition of "my work
today" rather than two that disagree. Services are the union of direct
assignment and protocol authorisation.

The two stat cards are links to the pages they name; a number with no
destination made the user hunt the sidebar for a page the card had just
mentioned. Each row of the work list opens that session. The avatar moves from
its own full-width card into the header — two lines of text were costing a card
and pushing the day's work below the fold on mobile. The assigned-appointments
table renders only when it has rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:16:00 +03:30
hamedandClaude Opus 5 384ea7b180 chore(graph): rebuild after treatment case date filter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:57:52 +03:30
hamedandClaude Opus 5 73963020e2 feat(treatment): date-range filter on treatment cases, and time on the start stamp
The list could be narrowed by status and by search but not by when a case
opened, which is the one axis a clinic actually reports on. `from` and `to`
(YYYY-MM-DD) now bound `opened_at`, using the same strtotime day-boundary
convention the appointment date filters already use under the app's global
Tehran timezone. A malformed value is ignored rather than erroring — this is a
filter, not a form field.

Both bounds live in the URL via useUrlState, so back and refresh keep the range.
The two date inputs and the "تا" between them are one nowrap unit; letting them
wrap separately orphaned the word from its field on a 390px screen.

The card's "شروع" showed only the Jalali date, so several cases opened on the
same day were indistinguishable on that line too. It now uses formatDateTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:57:14 +03:30
hamedandClaude Opus 5 2bb76258f6 chore(graph): rebuild after treatment case search and edit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:49:16 +03:30
hamedandClaude Opus 5 952e09bd6a feat(treatment): search and edit for treatment cases
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>
2026-08-07 13:48:57 +03:30
hamedandClaude Opus 5 00349cdb44 chore(graph): rebuild after session device read-only
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:00:44 +03:30
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
hamedandClaude Opus 5 369f3ae710 feat(seed): one command that builds three complete, working environments
Manual testing had no environment to test in: the demo seeder builds volume
(500 doctors, 20k appointments via raw INSERT) for the representation module,
which is the wrong shape for walking through a scenario end to end.

app:seed-scenarios builds three environments that each work from login to
booking:

  1. an independent doctor, service mode, with services, schedule, insurance,
     patients and appointments
  2. a doctor who also owns a clinic, with three more doctors inside it, a
     slot/service mix, two laser devices and two rooms, and laser services that
     genuinely require a laser
  3. a clinic whose owner is not a doctor, with all three booking modes live
     (slot, service and resource), five devices, and the same full data set

Everything goes through entities and the real services rather than raw SQL, so
tenant pairs, the unique active-slot key and the insurance rules hold. The
status machine is walked step by step (completed only via confirmed) instead of
writing a status the application could never produce.

--reset drops the schema, re-runs migrations and seeds base data in one go.
Three things it has to handle, each found by it breaking:

- representations must exist before cities, because cities.json references them
  by id and the category importer validates that
- migrations run mid-process invalidate the EntityManager's connection, so the
  manager is taken from the registry and reset afterwards
- a sub-command's --no-interaction in ArrayInput is not enough; without
  setInteractive(false) the migration waits forever for a confirmation

It also writes site_config.altcha_enabled = '0'. On a freshly migrated database
the captcha defaults to on and nobody can log in at all — panel or site.

Verified against the running app, not just the database: booking-locations
reports the right mode per doctor, service slots respect the buffer, the
slot-mode doctor returns a session with 15 slots, the resource-mode doctor
returns 40 options each with a real device assignment, a patient booked a
service appointment through the public endpoint, and the admin panel renders
the seeded day for both clinic owners.

TEST_USERS.md is rewritten: every account it described was gone after the wipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:41:03 +03:30
hamedandClaude Opus 5 cbff3f3e3e chore(graph): rebuild after the public-booking service fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:45:56 +03:30
hamedandClaude Opus 5 03f09637ed fix(booking): store the services and duration a public booking was made with
POST /api/v1/appointment resolved the selected services, summed their minutes,
used that to compute slot_end — and then dropped the result. It never called
replaceServiceItems() or setServiceDuration(), so an appointment booked from
the public site kept no record of what it was booked for:

- the patient panel showed neither the service nor the duration
- reports counted the appointment as having no services
- a later reschedule had no duration to preserve

The management path did all of this correctly; only the public path did not.
Found by booking through the real endpoint and looking at the panel, which is
the one thing no test did.

The duration was also computed as a naive sum of duration_minutes, ignoring the
solo/additional split. That made a multi-service booking's length disagree with
the slots appointment-service-slots had just offered the patient — the booking
would occupy a different span than the one shown. Both paths now go through
ServiceBookingCalculator, which is what builds those slots.

For data that only sets duration_minutes, the calculator returns the same total
as the old sum, so existing services are unaffected.

assertServicesMatchContext() is gone: the calculator performs the identical
ownership check with the same error code and message, and the tenant-lookup
inventory is updated to match.

Tests: PublicBookingServicePersistenceTest starts at the endpoint rather than
building an appointment in memory — the gap that let this ship. Verified it
fails (4 of 8) with the fix disabled. Full suite 1433 green, slot-mode-frozen
green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:43:23 +03:30
hamedandClaude Opus 5 df07d00adc docs(checklists): mark the public-site dark-mode rows out of scope
The product owner reverted the dark-mode work on nobat724_front, so six rows in
task-00b that either claimed dark mode was verified or listed it as debt no
longer describe reality. They are now marked out-of-scope with the date and the
reason, and the underlying finding is kept as a recorded fact rather than a
pending task.

Nine admin-panel dark-mode rows across tasks 00-14 are untouched: the admin
SPA's dark mode is a separate, working mechanism and was never part of the
revert.

Also closed nine panel visual-review rows with the screenshots taken this
session, and five public-site rows verified in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:12:11 +03:30
hamedandClaude Opus 5 5ae6c040c3 chore: refresh the knowledge graph after the final pass
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:41:09 +03:30
hamedandClaude Opus 5 e5b74ebab4 docs: settle every remaining row, and add the third occupancy mode
The last structural gap from task 05 was the third occupancy mode. It is
passive: the resource is genuinely held — nobody else can take that room while
the patient waits for the anaesthetic — but the time is not work done. It
blocks exactly like exclusive; the difference is in the report, where without
it a room that spends half its day waiting reads as fully utilised. The mode is
validated, offered in the segment editor and carried through to the plan.

Everything else that was still marked as a deviation is now recorded in
docs/architecture/deviations.md, one row each, in the form "what the plan said
/ what was built / why". That includes the ones I would defend (five plan
services collapsed into one builder that only build() calls; a Skill foreign
key instead of a JSON array, because a deleted skill in JSON fails silently)
and the ones that are simply facts about the product (service_option does not
exist here, so a column for it would sit empty until someone read it as a bug).

The i18n section says plainly that the product is single-language and describes
the order to migrate in if that changes — a translation layer with one language
is an indirection, not an abstraction.

All sixteen checklists now read zero pending and zero unresolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:40:55 +03:30
hamedandClaude Opus 5 bab7b57a9d refactor: take the three risky rows back to the plan, without the bugs they invited
All three were deviations I had argued for. Reversing them as asked, each in
the shape the plan wanted and with the failure it would otherwise cause closed.

consume now catches the unique-constraint violation, as specified, instead of
relying only on a read-before-insert. The read stays for the ordinary path, but
it never closed the race — only the unique key does. What made the catch
dangerous is that Doctrine closes the EntityManager on a constraint violation
and the rest of the request dies with it, so the catch resets the registry.
Without that, "already consumed" would surface as an unrelated 500. A test
inserts the ledger row from a second connection and then asks the service to
consume: it returns true, the manager is still open, and exactly one session is
taken.

Cancellation is one transaction now: status, capacity release, credit refund,
penalty and the timeline row commit together. An appointment marked cancelled
whose capacity was never released is the worst of both — the patient has no
appointment and nobody can take the slot. Notification stays outside the
commit, because an SMS cannot be rolled back and must not sit inside something
that can. A test with an SMS provider that always throws proves the
cancellation still commits.

The ledger's running balance is computed in the UI from the rows on screen. The
server still sends its own and remains the reference; the point of computing it
here is that the column now reflects the rows the user is actually looking at,
so a truncated list shows up as a mismatch rather than as a number nobody can
check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:28:30 +03:30
hamedandClaude Opus 5 4cc894e1f1 chore: refresh the knowledge graph after the engine and theme work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:15:39 +03:30
hamedandClaude Opus 5 e530ab5678 docs: record the dark-mode finding on the public checklist
The wiring is fixed and browser-verified, but components/appointment/ has no
dark: utilities at all, so the booking flow still renders identically in either
theme. That is design work, and the row says so rather than claiming done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:15:25 +03:30
hamedandClaude Opus 5 2db7a500e6 feat(reports): restore the documented sample threshold, and draw the chart
MIN_SAMPLE goes back to the specified 10. The reason it had been lowered to 3
was real — a small clinic saw an empty report — but the fix was wrong: three
samples do not make an average, and calling that "accurate" is worse than
saying nothing.

Rows below the threshold are now returned rather than dropped, with severity
null and below_min_sample true. That refuses both mistakes: it claims no
severity it cannot support, and it does not show a small clinic an empty page
that implies everything is fine. They sort after the usable rows and render
faded with a "small sample" badge.

The utilization page gets its Recharts bar chart. The table stays underneath —
six numeric columns are not something a chart answers — but the one question
the table is bad at, "which resource is behind", is exactly what a chart is
for. Colours come from the design tokens rather than hex, which is where a
chart usually breaks in dark mode, and a resource with no calendar is left out
entirely: null is not zero, and a zero bar would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:01:40 +03:30
hamedandClaude Opus 5 f2600f9922 refactor(policy): build the registries and six engines the architecture asked for
The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine
classes and a stored specificity. What shipped was a single PolicySchema
constant list, six operators, one resolver and a specificity recomputed on
every booking. Each shortcut was defensible on its own; together they left the
starred risk the task itself recorded — a field can be advertised in the form
and supplied by nobody, and the rule silently never matches.

OperatorRegistry now holds all eleven operators. The five that were missing are
real capability, not ceremony: greater_or_equal and less_or_equal make boundary
rules expressible without off-by-one, not_in is the natural way to write an
exclusion, between stops "18 to 65" needing two clauses, and days_since is the
documented operator for "more than N days since" — until now every caller
computed that by hand. between is inclusive at both ends because that is what
the Persian phrasing means and what the user will type.

FieldRegistry is now the single source: it builds the form schema and extracts
the value, so a field that exists in one and not the other is impossible. It
also declares which categories each field belongs to, which is what the closed
list per category used to do separately. Adding it immediately caught its own
first case — last_visit_at was advertised and supplied nowhere, so the guard
now populates it and days_since has something to read.

The six engines are thin on purpose. They give the call site a type — "the
pricing engine" rather than "the resolver with the string pricing" — and a
place for evaluateIsolated, which the sandbox needs to answer "what would this
one rule do". Conflict resolution and effect combination stay in
PolicyResolver: six copies of that would be six places to break.

specificity is a stored column now, computed on save with the documented
weights, and the migration backfills existing rows with the same formula. Left
at zero they would all have tied and the ordering would have changed overnight.

Field names stay as they are rather than moving to the document's dotted names
(patient.age). Stored condition_json rows point at the current names on live
clinic policies; renaming them is a data migration, and the mapping is not
one-to-one — implementation_notes.md says as much.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:56:50 +03:30
hamedandClaude Opus 5 60b4fb9b93 chore: refresh the knowledge graph after the final sweep
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:33:14 +03:30
hamedandClaude Opus 5 ca2f9b8652 feat(admin): build the last two screens, and pin spacing with a test
The cancellation policy page showed only the tenant policy, so nothing said
which services opt out of it. Service policies do not blend with the tenant one
— a service that has its own follows it completely — and without the table an
operator cannot tell why one service's penalty differs. It lists them with a
link to each service.

The waitlist had the matches endpoint and no way to reach it. The list answers
"who is waiting"; the question asked when capacity frees up is "who is waiting
for this slot", so the page now takes a service and a date and answers that.
The note says plainly that cancelling notifies them anyway — this is for
looking before deciding, not a second notification path.

Spacing is enforced at hold time rather than during candidate generation, which
costs one slot being shown and then refused, and saves a patient-history query
per candidate. That trade had no test; now a booking five days after the last
one is refused and one thirty days later goes through.

Checklists across all sixteen tasks are final: no pending rows, and the
warnings that remain are recorded decisions — one resolver instead of six
engines, a closed list instead of a registry, sample size three instead of ten
— each with the reason it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:32:58 +03:30
hamedandClaude Opus 5 f8d4e97e35 fix(admin): give the service catalog a real page header
The visual pass over the older screens found one inconsistency worth fixing:
the service catalog opened with a hand-rolled bold line and a button. No back
button, no description — the one page in the panel that does not say what it is
or how to leave it. It uses PageHeader now, like every other page.

Everything else on the task 01-04 screens held up under dark mode and compact
density: branches, resource types, skills, pools and the catalog all read from
tokens and none of them broke. The only remaining mobile findings are the
shared shell's Latin phone number and one small tap target, both of which
predate this work and appear on the dashboard too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:23:23 +03:30
hamedandClaude Opus 5 beaa11334c chore: refresh the knowledge graph after the debt and test pass
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:17:03 +03:30
hamedandClaude Opus 5 2baa2ce7ca test: cover the paths that were reasoned about but never executed
Fifteen rows across five tasks said the mechanism was there and the test was
not. Each of these is a case where being wrong would be silent.

- the price rows must add up to the final amount. The chain test checks every
  number individually, which stays green if a new row is added and left out of
  the total; this checks the relationship itself.
- a fixed deposit beats a percentage one, and neither can exceed the final
  amount — charging a deposit larger than the bill puts the patient in debt
  before the visit.
- an appointment booked without a service still gets an invoice. Slot mode has
  no service, and without this the financial report is short a row with nothing
  to say which.
- the four accuracy thresholds, each tested on its own boundary. One step off
  and either everything is red (so nobody looks) or nothing is (so the report
  is pointless). Includes a short-running service, since the deviation is
  measured on its absolute value.
- all six policy templates build a policy that survives the normal validation,
  simulation and activation path. A template is a shortcut, not a second road:
  if one of them produced something the validator rejects, a user could create
  a rule in one click that never works.
- simulation leaves nothing pending for a later flush in the same request. That
  is what the finally-rollback-clear is for, and the failure would surface in
  the next operation rather than in the sandbox.

The course controller was reading $this->credits without it being injected —
phpstan caught it; the package-shortfall path had no test yet and would have
500'd on the first course that had a package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:16:26 +03:30
hamedandClaude Opus 5 5c754244f2 feat(admin): finish the screens that were stopping one step short
Five places where the data existed and the screen did not use it.

Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.

A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.

The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.

Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.

A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:07:27 +03:30
hamedandClaude Opus 5 fe48b10fb5 fix(plan): stop the segment replace from destroying segments when it rejects
PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.

Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.

While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.

The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.

A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.

A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.

require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.

Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:01:38 +03:30
hamedandClaude Opus 5 496432889d chore: refresh the knowledge graph after the final checklist pass
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:37:49 +03:30
hamedandClaude Opus 5 d98a0396a4 test(policy): fail if the schema advertises a field nothing ever supplies
Task 09 left this as its starred risk and deferred it to task 10, which then
shipped without it. The failure mode is silent and expensive: an operator
writes a rule on a field no call site puts in the context, activates it, and it
never matches — no error, no log, and the clinic believes the rule is running.

The test is structural rather than behavioural on purpose. Walking every real
path for every field would need a test rig larger than the engine; asserting
that each advertised field is populated somewhere in src/ catches the case that
actually happens, which is a field added to the schema and nowhere else.

Also closes the last few rows that had gone stale:

- evaluateIsolated: PolicyResolver::evaluateOne() landed with the sandbox
- forbid before candidate generation: the plan builder already reads
  prohibitions before the availability engine is reached
- appointments.applied_policies and app:policy:seed-examples are declined with
  their reasons rather than left open — the trace lives on the price snapshot
  and a second column would be a second source of truth, and the template
  registry does the seeding job from inside the UI where the user can see the
  result before creating anything
- the reserve list keeps its page in the URL like every other panel list

Every checklist across the sixteen tasks now has zero pending rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:37:32 +03:30
hamedandClaude Opus 5 dedae05542 chore: refresh the knowledge graph after the seeding and batching work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:22:36 +03:30
hamedandClaude Opus 5 b3c331f0cb perf(reports): read every resource's calendar in one batch, and close the owed tests
Writing the query-count test that task 14 owed showed the growth was real: one
resource cost 10 queries, six cost 33 — about five per resource, because the
available-minutes figure walked each resource's calendar on its own.

Holidays, tenant overrides and branch hours are identical for every resource in
a report, so they now load once outside the loop; shifts and exceptions load for
all resources in one query each. The batched path is a new method rather than a
change to rawAvailability, which the booking engine also calls. The test pins
the shape of the growth, not an exact count.

Also landed:

- app:segment:seed-templates with beauty, dental and physio presets. Building
  four segments and their requirements by hand is the first thing a new clinic
  must do and the most tedious; this gives them something to edit instead of an
  empty page. It refuses to touch a service that already has segments unless
  --force, and it will not invent resource types the tenant never defined.
- book-all is all-or-nothing, proven rather than asserted: with a calendar open
  one day a week and a 1-2 day protocol gap, session one finds a slot and
  session two cannot, and every session must come back planned.
- credit_refundable: false takes the credit back with a negative adjustment and
  deletes nothing — the ledger stays append-only.
- the segments editor has frontend tests, including that it sends back what the
  user sees and renders read-only without the permission.

useBranches now returns [] for a non-array payload instead of throwing
"branches.map is not a function" and taking the page down with it.

BookingLocationsScanTest built a Clinic around a Doctor loaded from a different
manager, which Doctrine treats as a new entity; it flushed fine most runs and
failed on cascade in others. It now loads the doctor from the same manager.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:22:22 +03:30
hamedandClaude Opus 5 92edd175cc chore: refresh the knowledge graph after the risk-badge and URL-state work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:00:12 +03:30
hamedandClaude Opus 5 27c0b8f4f6 feat(patients): surface the no-show count, and put the report filters in the URL
The no-show records existed and drove the risk tag, but the patient's file
never showed the number behind it — the operator saw a tag with no evidence.
GET /patient/{uuid}/no-shows returns the count, the policy threshold and the
window, and the banner shows it only when the count is above zero: "0 no-shows"
on every healthy patient's file is an accusation nobody made.

The badge does not block anything and the docs say so. Blocking is an
eligibility policy from task 09 built on the same tag; a clinic that wants to
see the risk but still take a deposit must not have to switch the count off.
A test pins that a tagged patient still books.

Both report pages kept their range and branch in local state, so going back
from a resource lost the report and a shared link opened someone else's
default. They use useUrlState now, like every other list in the panel.

Three tests that were owed:
- the service-level cancellation policy beats the tenant one with no blending,
  checked through the number that comes out rather than through the resolver
- a patient over the no-show threshold can still book
- occupied includes the waiting segment while active does not — if those two
  came back equal the whole utilization report would be pointless

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:59:58 +03:30
hamedandClaude Opus 5 824e7f83c6 chore: refresh the knowledge graph after the treatment-course work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:51:47 +03:30
hamedandClaude Opus 5 e9e61adfee feat(course): show how the course is actually going, not just how it was planned
Three gaps on the treatment-course page, all of them about the difference
between the protocol and reality.

The sessions table listed each date but not the gap between them, leaving the
operator to subtract two Jalali dates in their head. It now shows the real gap
and colours it as a warning past the protocol maximum.

A course cancelled mid-way stretches silently: the session goes back to
planned and nobody is told. The suggestion endpoint does warn, but only once a
branch is picked, so the warning could go unseen indefinitely. The page now
derives "N days since the last session, past the protocol maximum" from the
course itself, so it shows immediately.

The course's preferred resource was applied by the engine but never named in
the UI. The API now returns preferred_resource_name alongside the uuid, and
the text says plainly that it is a preference — the engine moves it up the
list, it does not hold the slot.

Two backend tests that were owed: the stricter of the protocol spacing and a
spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a
clinic's safety rule could be bypassed by writing a short protocol), and a
session whose earliest possible date falls outside the 90-day horizon is
skipped rather than failing book-all, leaving the course untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:51:34 +03:30
hamedandClaude Opus 5 83704f9d30 chore: refresh the knowledge graph after the resource UI work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:45:39 +03:30
hamedandClaude Opus 5 000cf70761 feat(resources): warn before switching a resource off, and pick dates in Jalali
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.

It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.

The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:45:25 +03:30
hamedandClaude Opus 5 ba7cf7edc9 chore: refresh the knowledge graph after the segment-merge work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:18 +03:30
hamedandClaude Opus 5 47a40e2021 feat(plan): build the plan from the selected items too, so mergeable finally means something
The mergeable flag was stored, returned by the API and rendered in the editor
while changing nothing. The reason was upstream: the builder only ever read the
primary service's templates, and within one service two segments with the same
name do not occur — so the dedupe it already had could never fire.

Templates now come from the primary service plus every selected item, and
same-named mergeable segments collapse to one. Rules, with their reasons:

- the longest of the same-named segments survives — prepping two areas is not
  shorter than prepping the longer one alone
- a duration_source: "items" segment also appears once even when it is not
  marked mergeable, because DurationCalculator has already summed every item
  and repeating the segment counts that time twice
- the merged requirement count is the maximum, not the sum and not the first
  one seen: two areas do not need two rooms, but if one of them needed two
  operators, merging must not quietly demote that to one

Also pins that the plan is deterministic: two previews of the same input are
compared byte for byte. A plan that shifts between preview and booking means
the user confirmed something that was not what got booked.

Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded
searched "last week's Saturday", which is today when today is Saturday, so this
afternoon's slots were legitimately not in the past. It now searches two weeks
back, which is unambiguous on every weekday.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:05 +03:30
hamedandClaude Opus 5 c7811120d7 chore: refresh the knowledge graph after the waitlist work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:33:49 +03:30
hamedandClaude Opus 5 4bba322b8e feat(waitlist): make the day-part preference, the conversion and the expiry real
Three rows of task 13 were storing data nothing ever read.

`preferred_day_parts` was saved and displayed but never applied when matching.
It was deferred because "evening" has no fixed meaning — but branches already
carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned:
morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour.
The list is now closed and validated; an unknown part is a 422 rather than a
preference that silently matches nothing. The filter runs *before* the cut to
ten recipients — otherwise the first ten slots go to people who did not want
that hour and the real eleventh person is never told.

`markConverted()` was dead code: nothing called it. It now runs off the
AppointmentBooked domain event rather than from inside BookingService, because
converting is a side effect of booking — inside the booking transaction a
waitlist error could roll back the patient's actual appointment. The match is
deliberately narrow (same patient, same service, start inside the window); a
loose match closes a row the patient is still waiting on. It is idempotent, so
redelivery is harmless.

Expiry now exists as a service, a daily scheduled message and
`app:waitlist:expire`. Expired rows were already excluded from matching, so
this is display hygiene, not a behaviour fix: without it the waitlist page
fills with dead entries and the operator cannot tell which are still live. It
sets a status rather than deleting — who waited and never got a slot is data.

Also: a waitlist window is capped at 90 days, matching the booking horizon. An
unbounded window is a row that never expires and shows up in every match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:33:26 +03:30
hamedandClaude Opus 5 913713f632 chore: refresh the knowledge graph after the visual-pass fixes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:42:29 +03:30
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
Screenshotting the pages under dark mode and compact density (rather than
trusting that design tokens were enough) turned up two mistakes repeated across
every page this feature set added:

- `.card` carries only the surface, border and radius — padding comes from the
  separate `.card-pad`. Fifteen cards were rendering with their content flush
  against the edges.
- `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a
  control in it produced a joined addon rather than a label above its field.
  `.field-block` is the label-above layout, and thirty-seven wrappers now use it.

Both were invisible to type-checking and to the tests, which is exactly why the
visual pass was worth running. Numbers in the new UI now go through
formatNumber so they render as Persian digits, and the utilization page's
header no longer repeats the sentence that appears under its filters verbatim.

The QA driver gained a `--ui` flag: theme and density live in
localStorage['clinicpro-ui'], so without seeding them dark mode and compact
density cannot be screenshotted at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:42:11 +03:30
hamedandClaude Opus 5 29148b6bd3 chore: refresh the knowledge graph after the domain-event and panel work
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:28:25 +03:30
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:55 +03:30
hamedandClaude Opus 5 b55c33f686 chore: refresh the knowledge graph after checklist updates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:53:11 +03:30
hamedandClaude Opus 5 a8020e3c20 docs: reconcile checklists with what the last passes actually built
Rows closed by the resource-strategy and blocking work: task 06's picker
interface and four strategies, task 07's ad-hoc blocking and 409 recovery, and
task 12's same-as-previous preference, which had been blocked on task 06's
missing strategies since it was written.

Tasks 13 and 14 both carried the flaky-suite caveat against their final review;
that flake has a diagnosed cause and a fix, so both now say so instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:53:02 +03:30
hamedandClaude Opus 5 7f057f02ee chore: refresh the knowledge graph after resource blocking
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:51:11 +03:30
hamedandClaude Opus 5 0127b463a6 feat(resource): ad-hoc blocking, 409 recovery, and the rest of the flake
Ad-hoc resource blocking
- "The laser is being serviced this afternoon" is a specific range, not a change
  to the resource's working pattern. It stays separate from calendar exceptions
  and the modal says which is which — merging them means either an afternoon's
  closure lives in the calendar forever, or a change to working hours vanishes
  with one click
- Blocking a range that already holds an appointment is refused with 409 rather
  than silently taking capacity back; the appointment is still there and someone
  has to decide about it first
- Deleting an occupancy that belongs to an appointment is refused too, otherwise
  a patient's booking would quietly lose its resource with no record

409 on hold now recovers
Saying "someone just took it" is not enough — the operator would have to search
again by hand. The page drops the stale selection and refetches, so alternatives
are on screen immediately.

Flake, second half
The earlier fix only covered createUser's retry path. Any test that trips a
unique constraint closes the EntityManager, and the next test inherits the same
closed instance from the container. setUp now resets the registry when it finds
a closed manager, so a test's starting state no longer depends on how the
previous one failed.

Three consecutive full runs green: 1340 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:50:43 +03:30
hamedandClaude Opus 5 3dcc7c2ec0 chore: refresh the knowledge graph after resource strategies
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:21:38 +03:30
hamedandClaude Opus 5 aa6ea45a57 feat(availability): resource ordering strategies, and a real fix for the flaky suite
Strategies (task 06 debt, task 12 dependency)
- ResourcePicker orders candidates; it deliberately does not choose. Only the
  engine knows which resource actually fits this slot and which was already
  taken by another role, and a strategy that picked would have to duplicate
  both checks
- Four implementations behind a tagged iterator: first_available (name order,
  the previous behaviour and still the default because it is predictable),
  least_gap, least_loaded, same_as_previous
- least_gap and least_loaded are deliberate opposites and both are correct;
  choosing between them is a business decision, so it lives in settings
- same_as_previous lifts a course's preferred resource to the front and keeps
  everyone else behind it. A preference, not a filter: forcing the same
  operator would make the patient wait two weeks, which is worse than a
  different operator
- Availability accepts course_uuid to supply that preference, closing the
  dependency task 12 recorded against task 06
- An unknown strategy falls back at search time but is rejected at save time.
  Stale settings must not stop bookings; a user typing a wrong value must not
  believe it took effect

Test suite flake
createUser() retries on a mobile-number collision — db_test is never reset and
holds tens of thousands of users, so the random draw does collide. The failed
INSERT closes the EntityManager, and the retry asked the container for it
again, which hands back the *same closed instance*. So the retry threw, and
every later test in that process inherited a dead manager.

That is the intermittent "EntityManager is closed" on an unrelated,
always-different test that made roughly half of full runs red and never
reproduced in a subset. Resetting the registry gives a live manager back.
UserCollisionRetryTest pins it by closing the manager on purpose.

Two consecutive full runs are green: 1334 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:21:16 +03:30
hamedandClaude Opus 5 62f18b3c0d chore: refresh the knowledge graph after the resource booking flow
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:05:33 +03:30
hamedandClaude Opus 5 0074162bb1 feat(admin): resource-mode booking flow with a hold countdown
The engine from tasks 06 and 07 could find slots and hold them, but nothing in
the panel could actually book one.

- Search, hold, confirm stay three separate steps because they are three
  separate states: between seeing a slot and taking it the seat is still open,
  and between taking and confirming there is a deadline
- HoldCountdown reads the server's expires_at rather than starting its own
  timer at render: browser clock skew and network latency both cost seconds,
  and those seconds are exactly where a hold is lost. It turns urgent under a
  minute and tells the parent the moment it lapses
- Per-role resource swap offers only the resources the engine returned for that
  same slot. Listing every resource in the branch would let an operator pick
  one that was never free and collect a 409
- An empty result is not an error: the reason code renders as a sentence
  saying what to change
- Confirm requires a doctor and stays disabled until one is chosen — the
  endpoint rejects it anyway, and finding that out after the hold clock has
  been running is the wrong time

Reached from the appointments page as a separate action rather than folded into
the existing form: its search comes from the intersection of resource
calendars, not from one doctor's slots, and merging the two would confuse both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:05:12 +03:30
hamedandClaude Opus 5 4bdacdcc7b chore: refresh the knowledge graph after price lists and the invoice card
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:58:51 +03:30
hamedandClaude Opus 5 4bca659939 feat(admin): price lists and the appointment invoice card
Task 08's pricing chain was reachable only through the API, so a clinic could
not define a price list or see what a booked appointment was actually charged.

Price lists
- Draft / active / expired are shown as three states because they mean three
  different things operationally: a draft has no effect on today's price at all
- Activation is a separate action rather than a checkbox in the form, matching
  the backend rule that creating a list must not change anything
- "Copy" seeds a new list from an existing one starting the day the old one
  ends, since most lists are last quarter's with a few numbers moved
- "All branches" is an explicit option, not an empty field

Invoice card
- Renders the recorded chain down to the final amount, hiding zero rows so the
  card stays readable
- A missing invoice renders as a normal state, not an error: an appointment
  that was never confirmed has no invoice
- Says outright that the numbers are from the appointment's own date and later
  tariff changes do not move them — otherwise someone who edited a price
  yesterday reads today's older number as a bug

Also corrects task 08's checklist: its test section carried a copy-pasted "no
UI was built" note against rows whose tests have existed since the task
shipped. Replaced with the real test names and the two that genuinely are not
covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:58:29 +03:30
hamedandClaude Opus 5 1559a60994 chore: refresh the knowledge graph after the resource booking mode
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:51:53 +03:30
hamedandClaude Opus 5 d56c41c87e feat(admin): resource booking mode with a readiness guard
Task 06's engine could only be switched on through the API, and nothing
checked whether the environment was ready for it. Since the mode choice is
irreversible, picking it with no resources defined would lock a clinic into a
state where no appointment is ever computable.

Backend now refuses that: resource mode requires at least one active resource,
with a message that says what to define first. Same shape as the existing
service-mode guard, applied on both save paths.

The panel shows the same conditions as a ✓/✗ list before the choice is made,
each unmet one linking to where it gets fixed — a 422 after an irreversible
decision is the wrong place to learn about a prerequisite.

Also adds the search step (minimum 5 minutes) and extends the existing mode
cards to three rather than building a parallel component.

No strategy picker: task 06 never built the strategies, and an empty menu reads
worse than an absent one.

GET /api/v1/service-items now returns has_segments, computed with one aggregate
query for the whole list rather than one per service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:51:29 +03:30
hamedandClaude Opus 5 1d6855a2b0 chore: refresh the knowledge graph after the catalog and segment editors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:42:47 +03:30
hamedandClaude Opus 5 26a8e53b34 feat(admin): catalog groups and appointment segments editors
Tasks 04 and 05 shipped working engines that a clinic could only reach through
the API. Both now have the panel that makes them usable.

Groups tab
- Inline min/max per group, saved on blur, with the meaning of an empty maximum
  spelled out next to the field rather than left as folklore
- Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the
  server's own message, which is more precise than anything generic
- A live preview that calls the same service-selection/validate the public site
  calls, debounced 400ms. Two separate calculations would eventually show the
  operator and the patient different numbers
- The breakdown table shows which item was counted as the anchor and which as
  additional, so a surprising total explains itself

Segments tab
- Sequence, duration source, patient-present and mergeable per segment, plus
  resource requirements with an explanation attached to each occupancy mode
- A timeline bar whose widths are proportional to duration, with segments the
  patient is absent for drawn faded. That contrast is the whole point of task
  05: the waiting segment holds the room but frees the operator
- "No eligible resource" renders with a link to add one — an error with no
  route forward is a dead end

Task 05's checklist had been left on "not started" this whole time even though
its code shipped with the task; it is now filled in against reality.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:42:24 +03:30
hamedandClaude Opus 5 bd1f79f5e1 chore: refresh the knowledge graph after domain events and reports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:28:17 +03:30
hamedandClaude Opus 5 3c43955800 feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.

Events
- A closed list of names, because a consumer branches on the string and a
  one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
  serialised, so a consumer always fetches fresh rather than reading a stale
  detached entity
- record() deliberately does not flush: the event row commits with the change
  it describes, so a rolled-back transaction leaves no event behind. A test
  pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
  its error rather than deleting it, because a silently dropped event is a
  loss with no trace. app:events:prune only ever removes published rows

Reports
- Resource utilisation separates available, occupied and active minutes.
  The gap between occupied and active is what exposes a bad segment
  definition, and available is multiplied by capacity so a three-chair room
  does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
  by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
  flags both directions: running short wastes capacity that could have been
  sold. Its row links straight to editing that service's segments, because a
  report with no route to a fix does not get read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:27:54 +03:30
hamedandClaude Opus 5 a379111606 chore: refresh the knowledge graph after cancellation and waitlist
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:07:11 +03:30
hamedandClaude Opus 5 fba1555f22 feat(cancellation): cancellation policy, no-show tracking and a waitlist
Cancelling worked but had no policy behind it: no window, no penalty, nothing
happened to the deposit, and the no_show status had no effect at all.

Two rules that are expensive to get wrong, and both are load-bearing:
- The clinic cancelling its own appointment is never charged. That check is the
  first line of the calculation, not somewhere in the middle, so a later
  refactor cannot reorder it into charging patients for the clinic's decision.
- A penalty never exceeds what was actually paid. Anything above that is a
  debt, and debt belongs to billing, not to cancellation. An unpaid appointment
  is charged nothing and the response says why.

The default is no penalty at all — a penalising default would have made every
patient with a near appointment liable the moment this deployed.

No-shows are rows, not a counter on the patient: a counter loses which
appointment and when, which makes the 12-month window impossible. Crossing the
threshold adds an existing TenantTag; it never blocks the patient, because
blocking is an eligibility policy (task 09) written on top of that same tag.

Waitlist notifies up to ten matching people and the first to book wins. An
exclusive queue reads fairer but means a freed slot sits locked for half an
hour while someone ignores their phone — so the SMS says so explicitly instead.

Insufficient wallet balance does not fail the cancellation: the slot is freed
either way. A slot should not be held hostage to money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:06:48 +03:30
hamedandClaude Opus 5 d831ce2c1c chore: refresh the knowledge graph after treatment courses
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:33:34 +03:30
hamedandClaude Opus 5 fc504f4415 feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single
appointments, which is the exception rather than the rule.

- CourseProtocol per service: session count and three distinct spacings —
  min is the earliest that is clinically allowed, ideal is best, max is where
  the course starts losing its effect
- Starting a course creates every session up front as `planned` and copies the
  protocol's numbers and per-session params, so changing the protocol tomorrow
  leaves a running course alone
- Suggestions anchor on the last *completed* session, not the course start:
  when session 2 slips, session 3 moves with it
- Slots are ranked by distance from ideal, not by earliest available — day 21
  is worse than day 27 when 28 is the target
- book-all is all-or-nothing inside one transaction, with a moving anchor and a
  90-day horizon; sessions past the horizon stay planned and are reported, not
  treated as failures
- The effective minimum is the stricter of the protocol and the task-09 spacing
  policy, so a clinic rule never fights the protocol
- Cancelling one session returns only that session to planned; abandoning a
  course does not cancel its appointments, which stays an explicit decision

One active course per (patient, service) via active_course_key, the same
partial-uniqueness trick as Appointment::activeSlotKey.

Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the
patient record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:33:07 +03:30
hamedandClaude Opus 5 edf22e0552 chore: refresh the knowledge graph after the package ledger
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:23 +03:30
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30
hamedandClaude Opus 5 d6294242b7 chore: refresh the knowledge graph after the policy sandbox
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:44:50 +03:30
hamedandClaude Opus 5 bcfa87bfad feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:44:28 +03:30
hamedandClaude Opus 5 56bd1b474a chore: refresh the knowledge graph after the policy engine
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:47 +03:30
hamedandClaude Opus 5 584ea4067f feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:19 +03:30
hamedandClaude Opus 5 281420ab4d chore: refresh the knowledge graph after the pricing task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:42:46 +03:30
hamedandClaude Opus 5 34b07421bd feat(pricing): date-ranged price lists and immutable appointment invoices
Section 12 and the fifth closing rule: changing a price never changes an
already-booked appointment.

The pricing chain already existed and worked. Two things were missing. Tariff only
carries a year, so a rate change starting in Mehr could not be expressed — PriceList
now takes an explicit date range and Tariff remains the layer beneath it. And an
appointment stored a single number, so after a price change or a discount nobody
could say what those 2,400,000 rials were made of.

Price resolution walks four layers per service and takes the first hit: branch
override, then the covering price list, then the yearly tariff, then the service's own
price. The last one is the guarantee that a date no list covers still returns a price
rather than zero or an exception. breakdown.sources reports which layer answered, so a
surprising number can be traced instead of guessed at.

Two calculation decisions worth stating. Tax is computed on the patient's share, not
the gross — a patient does not pay tax on the portion the insurer covers. And a
discount larger than the amount floors the total at zero rather than going negative,
because a negative balance would mean the clinic owes the patient money, which nothing
downstream is built to mean.

A branch-specific list deliberately does not count as overlapping a general one; it
takes precedence instead. Treating them as a conflict would have made per-branch
exceptions impossible to express. Lists have no effect until activated, so drafting
next quarter's prices cannot disturb today's.

PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can
be edited is not a snapshot, and two invoices for one appointment would be two truths.
Corrections are a new row plus voiding the old one. Invoices are written during
confirm with the prices of that moment — computing later would let a rate change
between booking and invoicing produce a different number, which is exactly what rule
five forbids.

12 tests. The one that matters is
testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the
service price, watch quote return the new number while the appointment's invoice
returns the old one. Without it rule five is only a claim.

1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot
contract green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:42:21 +03:30
hamedandClaude Opus 5 cd12fabe14 chore: refresh the knowledge graph after the hold-and-book task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:29:21 +03:30
hamedandClaude Opus 5 4395eea56e feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:55 +03:30
hamedandClaude Opus 5 5d93208383 chore: refresh the knowledge graph after the availability engine task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:21:54 +03:30
hamedandClaude Opus 5 24534ec483 feat(availability): multi-resource availability engine
Section 10 of the design document, and the payoff for tasks 01–05. The engine slides
a multi-segment plan across resource calendars and answers which times are actually
possible, with a suggested resource for each role. Until now the only conflict the
system checked was the doctor's; rooms, devices and operators did not exist.

Allocation is per *role*, not per segment, and that is what returns the wasted
capacity. An operator with no requirement during "waiting for the cream" is simply
not examined for those minutes, so another patient can use them. The reference test
encodes exactly that: patient A holds 10:00–11:00 while the operator is only busy
10:00–10:05 and 10:35–11:00, and patient B is offered a slot inside the gap with the
second room assigned. The spec says the task is not verified without that scenario.

One resource is chosen for every segment that needs its role, not independently per
segment — otherwise the operator in segment 1 and segment 3 could be two different
people and the patient would change hands mid-treatment.

Occupancy is stored one row per (segment × resource) rather than one per appointment.
The granularity is the whole point; a row per appointment would re-create the
single-interval model the design rejects. Reserved intervals are widened by each
resource's setup/cleanup, because the resource genuinely is not available then.

booking_mode gains a third value, resource, alongside slot and service. It is purely
additive: the default stays slot, no environment moves on its own, and a location
that has not opted in keeps the untouched legacy path. The frozen slot-mode contract
stays green.

Performance is a test, not a hope: 30 days, 20 resources and 500 existing bookings
complete well inside the 500ms budget. Every input is read once and the rest is in
memory — no query inside the day or candidate loop — and candidates are generated
only from the free windows of the scarcest role, which turns tens of thousands of
candidates into a few hundred.

An empty result is not an error and not a 404: it carries
reason: "no_capacity_in_range" so the caller does not have to infer meaning from
emptiness.

Also fixed a genuinely intermittent test defect: NumericFieldNormalizerTest padded a
random number with the three-byte Persian "۰" using byte-based str_pad, producing
broken UTF-8 whenever the number was short. It failed roughly at random. The improved
assertion message added earlier is what identified it immediately.

1196 tests / 3414 assertions. phpstan at its 14-error baseline.

Resource-picking strategies, the availability cache and the settings UI are recorded
as outstanding in the checklist with reasons — the cache in particular would be
premature while the performance test passes comfortably without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:21:33 +03:30
hamedandClaude Opus 5 7cca433c56 chore: refresh the knowledge graph after the appointment plan task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:07:53 +03:30
hamedandClaude Opus 5 22c89fbae4 feat(plan): multi-segment appointments with per-segment resource requirements
Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.

AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.

Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.

Three contracts worth stating:

- A service with no segment templates falls back to a single continuous segment
  requiring the doctor resource — exactly today's behaviour. Without it every
  existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
  occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
  dropped requirement. Dropping it quietly would route the patient to a resource the
  clinic said must not serve them.

When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).

occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.

11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.

The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:07:30 +03:30
hamedandClaude Opus 5 04526542c9 chore: refresh the knowledge graph after the service catalog task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:44:24 +03:30
hamedandClaude Opus 5 b1b06c1b36 feat(catalog): dual durations, item groups, relations and branch overrides
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:44:02 +03:30
hamedandClaude Opus 5 0f4db93fb8 chore: refresh the knowledge graph after the resource calendar task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:29:13 +03:30
hamedandClaude Opus 5 4d722830e3 feat(resource): calendar UI, holiday admin, backfill and interval algebra
Completes task 03. The resource calendar page edits weekly shifts, records leave and
maintenance, and previews two weeks of availability with a Persian reason for every
empty day — showing the raw server key ("outside_branch_hours") to a user would have
been a meaningless message. The preview is labelled raw on the page itself, because
booked appointments are not subtracted yet and mistaking it for bookable time leads
to overbooking.

The interval algebra moved to src/Shared/Time/TimeInterval.php with twelve unit
tests: tasks 05 and 06 need the same union/intersect/subtract, and a second
implementation is how two subtly different definitions of "overlap" get born. The
half-open [start, end) contract is what makes a shift ending at 13:00 and one
starting at 13:00 not overlap.

AvailabilityQueryCountTest locks the query count flat: one day and ninety days cost
exactly the same number of queries. Without it the first refactor can put a query
inside the day loop and a 90-day response quietly becomes hundreds of queries —
something only production would reveal.

app:resource:calendar:backfill derives shifts from existing WeeklySchedule sessions,
so the resources created in task 02 are not left with empty calendars. It skips any
resource a user has already configured, which is also what makes it idempotent. The
weekly schedule itself is untouched: this is a copy, not a migration.

Also added --replace to the holiday import. upsert keys on the date, so a row written
with a *wrong* date can never correct itself — re-running just creates the right row
beside the wrong one. That is exactly what happened after fixing the Jalali
conversion bug, and it was caught while capturing real responses for the docs.

Deferred with reasons recorded in the checklist: seasonal shift validity (two
nullable columns can be added later without backfill, so "needed from day one" does
not hold), and a Jalali date picker in the exception form.

1154 tests / 3229 assertions. phpstan at its 14-error baseline, none in touched
files. tsc clean, vitest 622 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:28:48 +03:30
hamedandClaude Opus 5 1fdfdf9e48 feat(resource): resource calendars, exceptions and national holidays
Section 9 of the design document builds free time by subtracting seven layers.
Four existed and all of them hung off the doctor. This adds the missing ones and
puts them on the resource:

  branch hours ∩ resource shifts − national holidays − resource exceptions

Booked appointments and holds are deliberately NOT subtracted here — those are
tasks 06/07, as is intersecting several resources. The method is called
rawAvailability() so nobody mistakes the output for bookable time. Nothing in this
change calls SlotCalculatorService; the existing slot path stays frozen.

Four types of exception (leave, absence, maintenance, ad-hoc closure) share one
table because all four are "an interval subtracted from a resource's calendar";
splitting them would mean four queries per availability lookup instead of one.
Holiday overrides work in both directions: a clinic that opens on a public holiday,
and a clinic that closes on an ordinary day.

Every empty day carries a reason (national_holiday, no_shift, branch_closed,
outside_branch_hours, exception, …). Without it an empty response is
indistinguishable from a bug and the first person debugging has to read four tables
by hand.

Three real defects found on the way:

JalaliDateService.gregorianToJalali() was wrong — it returned [3006, 7, 3] for
2026-07-30 instead of [1405, 5, 8], roughly 1601 years off. jalaliYear(),
jalaliMonth(), jalaliMonthRange() and jalaliYearRange() all inherit that, so the
representation reports built on them have been filtering by nonsense ranges. The
class's own formatDateTime() was already correct because it used IntlDateFormatter,
so both conversions now go through the same mechanism, and JalaliDateServiceTest
pins Nowruz and the 6/31→7/1 boundary. There were no tests before, which is why
nobody noticed.

TimeInterval added a seconds-based midnight to a minutes-based interval, turning an
eight-hour shift into eight seconds. The conversion is now an explicitly named
minutesToAbsolute() so the unit change cannot happen silently again.

HolidayService.upsertNational() persisted but left flushing to the caller. Every
HTTP request reboots the kernel, so the caller often held a different
EntityManager: persist landed on one, flush on the other, and nothing was written
with no error at all. The write is now self-contained.

119 tests across tests/Resource, tests/Branch and tests/Representation. phpstan
clean on both touched domains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:14:23 +03:30
hamedandClaude Opus 5 73456447b2 chore: refresh the knowledge graph after the resource model task
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:52:02 +03:30
hamedandClaude Opus 5 04d3222559 feat(resource): admin UI for resources, types, skills and pools, plus real API docs
Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.

The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.

Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.

docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.

Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".

Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:51:38 +03:30
hamedandClaude Opus 5 964c09cc00 feat(resource): resource types, resources, skills and pools
The document's first golden rule is "the calendar belongs to the resource, not to
the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff
is a label on services and appointments with no calendar, capacity or skills. This
adds the layer underneath: anything that can be busy — doctor, operator, assistant,
device, room, bed, chair.

Two corrections to the planned schema:

- `address_id` → doctor_addresses, not `branch_id` → a new branches table. The
  branch already exists and is the address (task 01).
- UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per
  (doctor, clinic) but every session inside it carries its own location_id, so one
  doctor already works at several addresses within one environment. Keying on the
  doctor alone would have made that unrepresentable — and task 03 gives each
  resource its own calendar, which is exactly per-location.

Design points worth keeping:

- Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those
  three have live consumers (appointments.doctor_id, service_item_staff, the public
  site) and subclassing would mean migrating all of them at once. At most one bridge
  column is non-null, enforced in the entity because MariaDB will not reliably
  enforce a multi-column CHECK.
- Capacity is concurrency: a three-bed injection room is one resource with capacity
  3, not three resources, so occupancy in task 06 stays a COUNT against a limit
  instead of a merge of three calendars. A person resource is refused capacity > 1.
- Skills are a table, not rules. With 50 operators and 200 services, expressing
  "who may operate what" as policy would mean 10,000 rules.
- findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both;
  a plain IN would have matched a resource holding only one.
- setup/cleanup minutes occupy the resource without being part of the patient's
  appointment, and are per-resource — distinct from the existing per-doctor
  WeeklySchedule.meta.buffer_minutes, which stays untouched.

Two real bugs found by running the backfill against real data rather than fixtures:

ResourceLinker::systemType() persisted a type without flushing, so the next lookup
missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff'
for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work.

The command looped over every WeeklySchedule once per environment, which is
quadratic and never finished on real data. Doctors are now a single pass keyed by
the schedule's own environment. It also flushes per environment and accepts
--pair=clinic:12, so one bad row cannot close the EntityManager and abort a
fleet-wide run, and operators can re-run for a single clinic.

Staff are the one case that cannot be derived: nothing records which branch they
work at. Rather than guessing the first one and seating them in the wrong building,
multi-branch environments are skipped and reported.

88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on
src/Resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:34:36 +03:30
hamedandClaude Opus 5 92181bacff chore: refresh the knowledge graph after the branch/room task
17203 nodes, 27615 edges, 1276 communities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:49:17 +03:30
hamedandClaude Opus 5 d813843fcd feat(branch): admin UI for branch working hours and rooms, plus real API docs
Three pages, all on the existing design system: BranchesPage lists the current
environment's booking locations with their working-hours and active-room counts,
and two subpages edit the week and the rooms. The list page deliberately does not
create or rename a branch — clinic and doctor detail pages already do that, and
duplicating it would give one physical place two edit surfaces. Route permission
reuses `appointment_settings` rather than inventing a new one.

Two real bugs fell out of exercising this end to end:

`days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6
are sequential so json_encode collapses them to a list. The client reads days["0"]
either way, so nothing looked broken, but the response shape was unstable: one
missing day would flip the same field to an object. The controller now casts to
stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by
curling the endpoint for the docs, not by any test.

`<input type="time">` caps at 23:59, so it can neither display nor produce the
legal end value 1440. An all-day range would have vanished from the form and been
corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a
round-trip test proving 1440 survives.

docs/api/branch.md documents all eight endpoints with responses captured from real
curl runs against ddev, including the 422 and 404 bodies. doctor.md records that
active/timezone now appear on all nine existing address endpoints (additive), and
tenancy.md gains the two lessons this task taught: an aggregate child whose root is
itself declared global inherits no environment and needs a real pair, and
TenantFilter is not a substitute for an explicit ownership check because hard
isolation only applies to a *chosen* context.

Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract
green; phpstan 14 errors before and after, none in touched files; tsc clean;
vitest 87 files / 612 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:48:49 +03:30
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging
to it. That plan was wrong: the branch already exists and is called
`DoctorAddress`. It carries name, address, telephone, coordinates, city/province
FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system
already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id`
points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a
booking location, and nine CRUD endpoints plus four admin pages manage them.
A parallel table would mean two sources of truth for one physical place and a
branch that `location_id` never references.

So no `branches` table and no duplicate branch CRUD. Only the three genuinely
missing pieces:

- `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so
  existing rows need no backfill and no current behaviour changes. `active` is
  stored only — applying it to slot calculation is task 03, since touching
  `SlotCalculatorService` is off limits in this phase.
- `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight
  rather than "09:00" strings so range intersection stays arithmetic. PUT
  replaces all seven days; validation of the whole week runs before any DELETE,
  so an invalid sixth day cannot wipe the five valid ones and then answer 422.
- `rooms`, with `capacity` as concurrency (a three-bed injection room is one
  resource with capacity 3, not three resources) and a deletion-guard iterator
  so tasks 02 and 07 can add reasons without editing RoomService.

`BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`;
TenantSchemaCoverageTest rejected it correctly, because that root is itself
declared global. It now carries a real tenant pair instead, derived in the
constructor from the address's `type` — a total mapping, and the address is only
ever listed in its own context, so nothing is hidden wrongly.

RoomController checks ownership explicitly rather than trusting TenantFilter:
hard isolation only applies to a *chosen* context, so a doctor who had not
selected one could PATCH another clinic's room. Caught by
RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix.

35 tests, 97 assertions. Slot-mode frozen contract still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:28:04 +03:30
hamedandClaude Opus 5 a44cf8f9f7 chore(graph): refresh knowledge graph after task 00b
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:59:42 +03:30
hamedandClaude Opus 5 043713275c test(booking): freeze the public-site appointment contract; close task 00b
Adds PublicSiteAppointmentContractTest over GET /api/v1/appointments/user — the
endpoint the public site's user panel actually calls. Task 00's note claimed this
prerequisite was met by extending my/appointments, which is the admin panel's
endpoint; appointments/user returns Appointment::toArray(), which the same task
extended separately. The outcome was right, the reasoning in the note was not.
This test pins it so neither can drift silently: breaking these fields produces no
build error in either repo.

Documents why appointment-service-slots cannot be grouped into shifts by the
client, and records task 00b's checklist including the two items deliberately not
done (colour rewrite, reschedule button) with the evidence for each.

Task: docs/new_feture/taskes/task-00b-nobat724-service-mode/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:59:31 +03:30
hamedandClaude Opus 5 118b7f9fba chore(graph): refresh knowledge graph after task 00 documentation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:27:29 +03:30
hamedandClaude Opus 5 9891c2e44a docs(booking): document service booking mode and close task 00
docs/api/appointment.md gains the service-reschedule endpoint, the service-mode
section under PATCH, exclude_appointment_uuid and clinic_uuid on
appointment-service-slots, and the my/appointments additions. All JSON bodies are
real output captured from the running endpoints, not hand-written.

New docs/architecture/booking-modes.md holds the endpoint/mode matrix, the
duration contract with a worked example (35 + 10 buffer means a 45-minute step,
so 11:00 is not offered even though it looks free), the reserve-entry rules, and
a placeholder for the resource mode task 06 will add.

Also fixes a pre-existing flaky test that blocked a green suite:
NumericFieldNormalizerTest used a fixed national_code against db_test, which is
never reset, so depending on execution order the endpoint rejected it as a
duplicate. The test already looped for a unique mobile but not for the national
code. Out of this task's scope, fixed and declared so the definition of done is
actually green rather than apparently green.

phpstan was measured against the pre-task commit rather than asserted: 14 errors
in 9 files before, the same 14 in the same 9 files now.

Task 00 complete: 1026 tests green across three consecutive runs, 604 frontend
tests green, slot-mode contract frozen and verified.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:27:18 +03:30
hamedandClaude Opus 5 56a3c3c0d6 chore(graph): refresh knowledge graph after service duration backfill command
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:08:47 +03:30
hamedandClaude Opus 5 7482eb2ba3 feat(booking): add backfill command for service duration columns
Fills service_total_minutes/service_buffer_minutes on future service-mode
appointments booked before the columns existed.

The value comes from the appointment itself (slot_end - slot_start), not from
recomputing the services: an existing appointment may have been booked with a
manual duration and recomputing would rewrite the past. Slot-mode, past,
reserve and cancelled appointments are skipped.

Dry-run by default. Idempotency comes from the query filtering on
serviceTotalMinutes IS NULL rather than from a flag, so a second run has nothing
to do.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:08:35 +03:30
hamedandClaude Opus 5 f1ea7bb161 docs(booking): record task 00 checklist through feature 8
Marks two task-text assumptions as unfounded with the evidence (DataTable
migration, i18n file), defers the URL-state row to a task that owns it, and logs
the three pieces of work discovered mid-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:05:23 +03:30
hamedandClaude Opus 5 9502ed61f6 chore(graph): refresh knowledge graph after reserve conversion fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:04:21 +03:30
hamedandClaude Opus 5 4fbedecec1 fix(booking): reserve conversion produced a zero-length midnight appointment
TransferReserveModal built the live appointment from appointment_time/end_time,
which on a reserve entry are both 00:00 because slot_start == slot_end. Moving a
reserve back to the appointment list silently created a zero-length appointment
at midnight. With the new duration validation it would now fail loudly instead.

Converting back now asks for a real time: the service picker in service mode,
two required time inputs in slot mode. The appointment -> reserve direction is
untouched.

GET /my/appointments has its own array-hydration serializer rather than
Appointment::toArray(), so it exposed none of the service fields the panel needs.
Added service_items (separate query, no row multiplication and no N+1),
clinic_uuid and the duration pair. This was also a hidden prerequisite of the
public-site task, whose checklist listed it as "verify first".

The reserve table now lists every service instead of only the first.

Not done, deliberately: the DataTable migration the task asked for. Its stated
reason — inline tokens breaking dark mode — does not hold; this table's th/td
already use CSS variables and dark mode works. Rewriting a working table for no
real gain is unjustified risk.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:04:09 +03:30
hamedandClaude Opus 5 96095c05f3 chore(graph): refresh knowledge graph after edit page service mode
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:57:32 +03:30
hamedandClaude Opus 5 50759bf663 feat(admin): service-aware time picking on the appointment edit page
In service mode the page now mounts the existing ServiceSlotPicker and hides the
three free-form time inputs plus the single-service select: a 45-minute service
could previously be shortened to 20 and the next patient would sit on top of it.
Hidden rather than disabled — a disabled field reads as "you must do something
here".

Saving splits in two: the service-aware endpoint takes the time and services
(the client sends no duration), then the usual PATCH carries deposit, insurance,
status and note without slot_start/slot_end/version, since the reschedule already
advanced the optimistic-lock version.

Booking mode is read from the appointment's own schedule via an explicit
clinic_uuid, not from the panel's current environment: a doctor can be slot-based
in their office and service-based in a clinic. That required exposing clinic_uuid
in Appointment::toArray(), which was missing.

appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of
that appointment — an ungated parameter would let anyone fabricate availability.

ServiceSlotPicker gained two optional props; its existing callers pass neither and
are unaffected. Its reset-on-doctor-change effect now skips the first run, which
would otherwise wipe the initial selection.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:57:21 +03:30
hamedandClaude Opus 5 0051205bf2 chore(graph): refresh knowledge graph after service-aware reschedule
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:03:15 +03:30
hamedandClaude Opus 5 bfe7f36a45 feat(booking): add service-aware reschedule endpoint
POST /api/v1/appointment/{uuid}/service-reschedule takes only a start time and
derives the length from the appointment's services. PATCH also validates the
duration, but the client must already know the correct slot_end; not needing that
knowledge is what lets the edit form drop its manual time inputs.

The start must be a member of getServiceStartTimes(), not merely free:
isSlotTaken() reports collisions with other appointments, while the offered list
also applies shift bounds, holidays, date overrides, the booking window and the
buffer. Without it a secretary could park an appointment at 3am.

forManagement comes from canManageContext(), not canManage(): a patient moving
their own appointment must still respect the public booking window.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:03:04 +03:30
hamedandClaude Opus 5 231735162e docs(booking): record task 00 checklist state through feature 5
Also logs three side findings as debt rather than silently passing: an
unreproduced flaky failure in the combined suite, a pre-existing PHPUnit notice,
and db_test having a migration history separate from dev (later tasks will need
the same manual ALTER).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:59:09 +03:30
hamedandClaude Opus 5 02cceb1c72 chore(graph): refresh knowledge graph after PATCH service duration validation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:57:59 +03:30
hamedandClaude Opus 5 b589a851d0 feat(booking): make PATCH derive appointment duration from its services
In service mode PATCH accepted any duration and only updated the single
service_item column while the service_items collection stayed untouched, so an
edit could leave an appointment with old services and a new length. A 45-minute
service could be shortened to 20 and the next patient would sit on top of it.

Services are now resolved before the time block (duration depends on them) and
the stored end must equal start + total minutes. Reserve entries are exempt:
they carry slot_start == slot_end and occupy no interval, but they do store the
computed duration so a later conversion does not lose it.

No convert-reserve endpoint was added: PATCH already converts a reserve to a
timed appointment via rescheduleTo($start, $end, $isReserve), which refreshes
active_slot_key itself. Project rule 8 — a new endpoint needs an existing one to
be insufficient even after extension.

Slot mode is untouched: with booking_mode = slot the duration stays null and not
one of the new branches runs. Covered by an explicit test.

New error codes are ERR_APPOINTMENT_003/004 (the file only had 001/002).

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:57:48 +03:30
hamedandClaude Opus 5 2dae76cac0 chore(graph): refresh knowledge graph after exclude-self slot search
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:51:40 +03:30
hamedandClaude Opus 5 b784124b7e feat(booking): let service slot search exclude the appointment being moved
findBusyIntervals() and getServiceStartTimes() gain an optional
excludeAppointmentId, mirroring isSlotTaken($doctor, $start, $end, $excludeId)
which already had it. Without it an appointment being rescheduled sees itself as
busy, so its current time never appears among the candidates and "same hour,
different service" is impossible.

The parameter is optional with a null default and only affects the service-mode
path; no existing call site changes behaviour. SlotModeFrozenTest caught the
signature change immediately while both response contracts stayed green, so the
signature fixture was updated once with a written rationale, as its own header
permits.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:51:29 +03:30
hamedandClaude Opus 5 7eb0b1566f chore(graph): refresh knowledge graph after appointment service fields
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:47:59 +03:30
hamedandClaude Opus 5 6c2e075eea feat(booking): persist service duration and allow full service replacement
Appointment gains:
  - replaceServiceItems(): full replacement that unconditionally syncs the
    legacy single serviceItem column. addServiceItem() only fills it when null,
    which would leave stale service names in the four consumers that read
    service_item (admin lists, public site, desktop app).
  - currentServiceUuids(): input-order uuids, falling back to the single column
    for appointments created before multi-service support.
  - service_total_minutes / service_buffer_minutes (both nullable, NULL in slot
    mode). slot_end - slot_start carries the number but cannot say whether it
    was intentional, and a reserve entry has slot_start == slot_end so its
    duration had nowhere to live.

Existing columns untouched: slot_start, slot_end, active_slot_key, is_reserve
verified unchanged via SHOW COLUMNS.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:47:48 +03:30
hamedandClaude Opus 5 aaa864f408 chore(graph): refresh knowledge graph after ServiceBookingCalculator extraction
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:41:34 +03:30
hamedandClaude Opus 5 6afc5c090e refactor(booking): extract ServiceBookingCalculator from the controller
"Allowed duration of a service combination" lived inside
AppointmentController::serviceSlots(). Three upcoming callers need the same
computation (PATCH duration validation, service-aware reschedule, reserve
conversion); copying it would mean four variants with four different edge-case
behaviours.

The extraction is behaviour-preserving: BaseController::error() and
ExceptionSubscriber emit an identical envelope, so returning $this->error() was
replaced by throwing AppException with the same code/message/field.

Tenant ownership now goes through TenantOwnershipChecker::belongsToPair() (the
documented single point) instead of an inline section pair comparison. The repo
property is named itemRepo on purpose: TenantLookupInventoryTest only counts
recognised property names, so any other name would slip past the safety net.

The naive duration sum is kept deliberately — switching to solo/additional
minutes is task 04 and changes one line here.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:41:13 +03:30
hamedandClaude Opus 5 113e8d93a0 chore(graph): refresh knowledge graph after slot-mode freeze
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:32:26 +03:30
hamedandClaude Opus 5 c37c0cee39 test(booking): freeze slot-mode contract before service-mode work
Adds SlotModeFrozenTest (#[Group('slot-mode-frozen')]) locking three things
against the multi-resource booking phase:
  - GET /api/v1/appointment-slots response shape
  - GET /api/v1/appointment-settings/month-availability/{uuid} response shape
  - public method signatures of SlotCalculatorService

Fixtures are structural, not raw snapshots: a fixed past date is rejected by
isWithinBookingWindow so an empty snapshot would prove nothing. Instead a
deterministic schedule on a computed near-future date, with epoch/uuid values
normalized to placeholders. What stays locked is the contract itself: keys,
ordering, types and local times.

No production code touched.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green, 3 tests)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:31:57 +03:30
hamed f2ecaf201a Add JSON files for task 14 events utilization documentation
- Created new JSON file for database.md with nodes and edges representing the structure and relationships within the document.
- Created new JSON file for architecture.md with nodes and edges detailing the architecture of the task 14 events utilization.
2026-07-30 12:16:27 +03:30
hamed 70739691d1 Add checklists for tasks 11 to 14 covering credit ledger, treatment course, cancellation policies, and event utilization
- Created checklist for task 11: Package and Credit Ledger
- Created checklist for task 12: Treatment Course
- Created checklist for task 13: Cancellation Policy, No-Show, and Waitlist
- Created checklist for task 14: Domain Events and Utilization Reports
2026-07-30 12:12:45 +03:30
hamed 158dcb58aa feat: implement service mode completion for nobat724_front
- Add task for completing service mode in clinicpro with detailed objectives and acceptance criteria.
- Create architecture documentation for task 00b, outlining involved components and necessary changes.
- Develop checklist for task 00b to ensure all requirements are met.
- Document implementation notes for task 00b, emphasizing API contract checks and design system adherence.
- Update task documentation for task 00b, specifying goals and current issues with service mode.
2026-07-30 11:56:08 +03:30
hamed 021d0eb6b2 feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
2026-07-30 11:43:58 +03:30
hamed 1d338503c8 feat: enhance StaffPage modals and form fields with improved layout and error handling 2026-07-30 10:30:39 +03:30
hamed 57aeb40934 feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services.
- Created StaffMyServicesPage to display assigned services for staff users.
- Added migration to link clinic staff rows to user accounts for ROLE_STAFF access.
- Defined StaffPermissions class for static permissions related to staff role.
- Introduced StaffRouteGuardSubscriber to restrict API access for staff users.
- Developed StaffAccountService for managing staff user accounts and linking them to clinic staff.
- Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment.
- Implemented tests for staff dashboard access to ensure proper permissions and access control.
- Created tests for staff login context to verify correct environment visibility based on user roles.
2026-07-30 10:18:41 +03:30
2164 changed files with 309914 additions and 35448 deletions
+20 -2
View File
@@ -1,6 +1,6 @@
# راهنمای مشترک `prompt-writer` و `run-prompt`
# راهنمای مشترک اسکیل‌های این پروژه
این فایل مرجع کیفیت هر دو اسکیل است. هر دو اسکیل باید در ابتدای بدنهٔ خود این خط را داشته باشند:
این فایل مرجع کیفیت همهٔ اسکیل‌های پروژه است. هر اسکیل باید در ابتدای بدنهٔ خود این خط را داشته باشد:
> قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن.
@@ -8,6 +8,24 @@
---
## ۰. Grill — قبل از هر تغییر (اجباری)
قبل از نوشتن کد، ساختن فایل یا اجرای هر تغییر:
1. ابزار Skill را با `skill: "mattpocock-skills:grilling"` صدا بزن.
2. طبق آن اسکیل، سؤال‌ها را **یکی‌یکی** بپرس و بعد از هر سؤال منتظر جواب بمان.
3. هر چیزی که با ابزار قابل کشف است را نپرس. خودت پیدا کن.
4. اول `graphify query "<سؤال>"` بزن، بعد فایل بخوان.
5. فقط **تصمیم‌ها** را از کاربر بپرس، نه **واقعیت‌ها**.
6. برای هر سؤال، پاسخ پیشنهادی خودت را هم بنویس.
7. تا وقتی کاربر «تأیید» نداده، هیچ فایلی را تغییر نده.
خروجی این مرحله یک درک مشترک است. بعد از تأیید، برو سراغ §۱.
**استثنا:** اگر کاربر گفت «بدون grill»، «مستقیم انجام بده» یا «سؤال نپرس»، این بخش را رد کن.
---
## ۱. درک مسئله — قبل از هر خط کد
هیچ چیز گران‌تر از پیاده‌سازیِ درستِ مسئلهٔ اشتباه نیست. به همین دلیل:
+520
View File
@@ -0,0 +1,520 @@
# راهنمای قدم‌به‌قدم صفحات پنل ادمین — فاز ۱ (زیرساخت + صفحهٔ نوبت‌ها)
## پروژه
`clinicpro` — پنل ادمین React داخل `assets/admin/`.
## زمینه
پنل ادمین حدود ۵۰ صفحه دارد و هیچ راهنمای درون‌برنامه‌ای ندارد.
کاربر تازه‌وارد نمی‌داند هر بخش صفحه چه کار می‌کند.
تصمیم گرفته شد راهنما به شکل **تور قدم‌به‌قدم** باشد.
یعنی المان‌ها یکی‌یکی highlight می‌شوند و کنارشان یک popover فارسی توضیح می‌دهد.
این فایل فقط **فاز ۱** است.
فاز ۱ = زیرساخت تور + پیاده‌سازی روی یک صفحهٔ نمونه.
صفحهٔ نمونه: `AppointmentsPage`.
دلیل انتخابش: شلوغ‌ترین صفحهٔ پنل است و المان‌های نقش‌محور دارد.
بعد از تأیید ظاهر و رفتار تور، فاز ۲ نوشته می‌شود که همین الگو را روی بقیهٔ صفحات تکرار می‌کند.
**در این فاز هیچ صفحهٔ دیگری را دست نزن.**
## مشکل / هدف
هدف:
- یک زیرساخت واحد برای تعریف تور هر صفحه.
- تعریف تور به‌صورت داده باشد، نه کد پراکنده در صفحات.
- المان‌های هدف با اتریبیوت `data-tour` مشخص شوند.
- استپی که المانش در DOM نیست بی‌سروصدا حذف شود، نه اینکه تور بشکند.
- بار اول ورود کاربر به صفحه، تور خودکار اجرا شود.
- بعد از دیدن، دیگر خودکار اجرا نشود؛ ولی با یک دکمهٔ `؟` قابل اجرای دوباره باشد.
- محتوای تور نسخه‌دار باشد؛ با بالا بردن نسخه، تور دوباره یک‌بار خودکار اجرا شود.
## تصمیم فنی — چرا driver.js
سه گزینه بررسی شد:
- `react-joyride` — سنگین‌تر و روی React 19 مشکوک است. `react-floater` هنوز peer آن React 18 است.
- کامپوننت دست‌ساز — منطق overlay و اسکرول و resize و sticky header باید از صفر نوشته شود. کد زیاد و باگ‌خیز.
- `driver.js` نسخهٔ ۱ — بدون dependency، حدود ۵ کیلوبایت gzip، مستقل از فریم‌ورک، خودش overlay و اسکرول و reposition را دارد.
انتخاب: **`driver.js`**.
استایلش با توکن‌های `styles.css` override می‌شود تا با تم روشن و تیره یکی شود.
راست‌چین بودن مشکلی ندارد چون ریشهٔ اپ `dir="rtl"` است.
نصب:
```bash
ddev exec npm install driver.js@^1.3.6
```
## معیار پذیرش
- ✅ موفق: ورود با `09390039833` به `/admin/appointments` برای اولین بار → تور خودکار اجرا می‌شود. متن‌ها فارسی‌اند. شمارنده «۱ از N» است. دکمه‌ها «بعدی / قبلی / باشه، فهمیدم». بعد از پایان، در `localStorage['clinicpro-tours']` کلید `seen.appointments` برابر نسخهٔ تور می‌شود. رفرش صفحه → تور دیگر خودکار اجرا نمی‌شود. کلیک روی دکمهٔ `؟` کنار عنوان → تور دوباره از استپ اول اجرا می‌شود.
- ❌ خطا: `useTour('does-not-exist')` → هیچ دکمه‌ای رندر نمی‌شود، هیچ خطایی throw نمی‌شود و تور اجرا نمی‌شود. همچنین اگر هیچ‌کدام از المان‌های تور در DOM نباشد، `start()` هیچ کاری نمی‌کند و crash نمی‌دهد.
- ⚠️ مرزی: ورود با نقش `doctor` که تب پزشکان ندارد، و منشیِ بدون مجوز `appointments.create` که دکمهٔ «افزودن نوبت» ندارد → استپ‌های مربوط به آن المان‌ها حذف می‌شوند، تور با استپ‌های کمتر اجرا می‌شود و شمارنده درست است، مثلاً «۱ از ۵» نه «۱ از ۷». همچنین بالا بردن `version` تور → یک‌بار دیگر خودکار اجرا می‌شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/lib/tour/types.ts` | جدید — تعریف تایپ استپ و تور |
| `assets/admin/lib/tour/resolveSteps.ts` | جدید — تابع خالص فیلتر استپ‌ها بر اساس وجود المان |
| `assets/admin/lib/tour/registry.ts` | جدید — رجیستری تورها بر اساس id |
| `assets/admin/lib/tour/tours/appointments.ts` | جدید — تعریف تور صفحهٔ نوبت‌ها |
| `assets/admin/stores/tourStore.ts` | جدید — zustand persist برای تورهای دیده‌شده |
| `assets/admin/hooks/useTour.ts` | جدید — اجرای تور و اجرای خودکار بار اول |
| `assets/admin/components/ui/TourButton.tsx` | جدید — دکمهٔ `؟` راهنمای صفحه |
| `assets/admin/components/ui/PageHeader.tsx` | تغییر — پراپ اختیاری `tourId` |
| `assets/admin/pages/AppointmentsPage.tsx` | تغییر — افزودن `data-tour` و دکمهٔ راهنما |
| `assets/admin/styles.css` | تغییر — override استایل popover با توکن‌ها |
| `package.json` | تغییر — افزودن `driver.js` |
## وضعیت فعلی
`PageHeader` هیچ جای راهنما ندارد. کد فعلی:
```tsx
interface Props {
title: string;
breadcrumbs?: Crumb[];
action?: React.ReactNode;
description?: string;
backTo?: string;
}
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
...
<h1 className="section-title">{title}</h1>
```
`AppointmentsPage` از `PageHeader` استفاده نمی‌کند و عنوان دست‌ساز دارد. کد فعلی از خط ۴۲۰:
```tsx
return (
<div style={{ padding: '20px 24px' }}>
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
{/* عنوان */}
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
{/* نوار آمار */}
<TurnsStatInfo stats={stats} />
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
}}>
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
<ServiceFilterSelect ... />
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
<div style={{ flex: 1 }} />
{/* سمت چپ: فیلتر + افزودن نوبت */}
<button aria-label="فیلترها" className="btn sm" onClick={() => setFiltersOpen(true)} ... >
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button>
{!isRepresentation && canCreateAppt && (
<button className="btn primary sm" onClick={...}>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن نوبت
</button>
)}
</div>
```
الگوی persist موجود در `stores/uiStore.ts` مرجع است:
```ts
export const useUiStore = create<UiState>()(
persist(
(set, get) => ({ ... }),
{ name: 'clinicpro-ui', onRehydrateStorage: ... },
),
);
```
## وظایف
### ۱. نصب driver.js
```bash
ddev exec npm install driver.js@^1.3.6
```
**نحوه تست:** `driver.js` در `dependencies` فایل `package.json` باشد و `ddev exec yarn dev` بدون خطای resolve تمام شود.
---
### ۲. تایپ‌ها و تابع خالص resolve
`assets/admin/lib/tour/types.ts`:
```ts
export interface TourStep {
/** مقدار اتریبیوت data-tour روی المان هدف */
anchor: string;
title: string;
body: string;
side?: 'top' | 'bottom' | 'left' | 'right';
}
export interface TourDefinition {
/** شناسهٔ یکتا؛ معمولاً هم‌نام مسیر صفحه */
id: string;
/** با هر تغییر محتوای تور یکی زیاد شود تا تور یک‌بار دیگر خودکار اجرا شود */
version: number;
steps: TourStep[];
}
```
`assets/admin/lib/tour/resolveSteps.ts`:
```ts
import type { TourStep } from './types';
export function anchorSelector(anchor: string): string {
return `[data-tour="${anchor}"]`;
}
/**
* فقط استپ‌هایی می‌مانند که المانشان همین حالا در DOM هست.
* دلیلش نقش‌محور بودن صفحات است: دکمهٔ «افزودن نوبت» برای منشیِ بدون مجوز
* اصلاً رندر نمی‌شود و تور نباید روی یک المان غایب گیر کند.
*/
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
}
```
**نحوه تست:** تست واحد `lib/tour/resolveSteps.test.ts` با vitest و jsdom:
استپ موجود می‌ماند، استپ غایب حذف می‌شود، ترتیب استپ‌های باقی‌مانده حفظ می‌شود، آرایهٔ خالی → خروجی خالی.
---
### ۳. رجیستری تورها
`assets/admin/lib/tour/registry.ts`:
```ts
import type { TourDefinition } from './types';
import { appointmentsTour } from './tours/appointments';
/** هر صفحه یک فایل جدا در tours/ دارد؛ اینجا فقط ثبت می‌شود. */
export const TOURS: Record<string, TourDefinition> = {
[appointmentsTour.id]: appointmentsTour,
};
export function getTour(id?: string): TourDefinition | null {
return id ? TOURS[id] ?? null : null;
}
```
هر تور در فایل خودش، تا فاز ۲ فقط «فایل جدید + یک خط ثبت» باشد.
`assets/admin/lib/tour/tours/appointments.ts`:
```ts
import type { TourDefinition } from '../types';
export const appointmentsTour: TourDefinition = {
id: 'appointments',
version: 1,
steps: [
{ anchor: 'appointments-stats', title: 'آمار امروز', body: 'تعداد کل نوبت‌ها، انجام‌شده‌ها، در انتظار و لغوشده‌های همین روز.', side: 'bottom' },
{ anchor: 'appointments-date', title: 'انتخاب روز', body: 'با فلش‌ها یک روز جلو و عقب بروید یا از تقویم یک تاریخ را انتخاب کنید.', side: 'bottom' },
{ anchor: 'appointments-service', title: 'فیلتر خدمت', body: 'فقط نوبت‌های یک خدمت مشخص را ببینید.', side: 'bottom' },
{ anchor: 'appointments-view', title: 'نمای تایم‌لاین یا جدول', body: 'تایم‌لاین ساعت‌های روز را نشان می‌دهد و جدول فهرست ساده است.', side: 'bottom' },
{ anchor: 'appointments-filters', title: 'فیلترهای بیشتر', body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار.', side: 'bottom' },
{ anchor: 'appointments-new', title: 'ثبت نوبت جدید', body: 'برای همان روز و همان پزشکِ انتخاب‌شده نوبت ثبت می‌کند.', side: 'bottom' },
{ anchor: 'appointments-doctors', title: 'تب پزشکان', body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.', side: 'bottom' },
{ anchor: 'appointments-list', title: 'فهرست نوبت‌ها', body: 'با کلیک روی هر نوبت وارد جزئیات و عملیات آن می‌شوید.', side: 'top' },
],
};
```
**نحوه تست:** تست واحد بررسی کند `id` تور خالی نیست، `version` عدد مثبت است و `anchor`ها تکراری نیستند.
---
### ۴. استور تورهای دیده‌شده
`assets/admin/stores/tourStore.ts` — دقیقاً الگوی `uiStore`:
```ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface TourState {
/** tourId → نسخه‌ای که کاربر دیده است */
seen: Record<string, number>;
markSeen: (id: string, version: number) => void;
isSeen: (id: string, version: number) => boolean;
/** بدون آرگومان یعنی پاک کردن همهٔ تورها */
reset: (id?: string) => void;
}
export const useTourStore = create<TourState>()(
persist(
(set, get) => ({
seen: {},
markSeen: (id, version) => set((s) => ({ seen: { ...s.seen, [id]: version } })),
isSeen: (id, version) => (get().seen[id] ?? 0) >= version,
reset: (id) => set((s) => {
if (!id) return { seen: {} };
const next = { ...s.seen };
delete next[id];
return { seen: next };
}),
}),
{ name: 'clinicpro-tours' },
),
);
```
**نحوه تست:** `stores/tourStore.test.ts` — ابتدا `isSeen('x', 1) === false`؛ بعد از `markSeen('x', 1)` برابر `true`؛ با `isSeen('x', 2)` دوباره `false`؛ `reset('x')` پاکش می‌کند.
---
### ۵. هوک useTour
`assets/admin/hooks/useTour.ts`:
```ts
import { useCallback, useEffect, useRef } from 'react';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import { getTour } from '../lib/tour/registry';
import { anchorSelector, resolveSteps } from '../lib/tour/resolveSteps';
import { useTourStore } from '../stores/tourStore';
interface Options {
/** وقتی true شد یعنی دادهٔ صفحه آمده و المان‌ها رندر شده‌اند */
ready?: boolean;
}
export function useTour(tourId?: string, { ready = true }: Options = {}) {
const tour = getTour(tourId);
const markSeen = useTourStore((s) => s.markSeen);
const isSeen = useTourStore((s) => s.isSeen);
const autoStarted = useRef(false);
const start = useCallback(() => {
if (!tour) return;
const steps = resolveSteps(tour.steps);
if (steps.length === 0) return;
const d = driver({
showProgress: true,
allowClose: true,
overlayOpacity: 0.55,
popoverClass: 'cp-tour',
nextBtnText: 'بعدی',
prevBtnText: 'قبلی',
doneBtnText: 'باشه، فهمیدم',
progressText: '{{current}} از {{total}}',
steps: steps.map((s) => ({
element: anchorSelector(s.anchor),
popover: { title: s.title, description: s.body, side: s.side ?? 'bottom', align: 'start' },
})),
onDestroyed: () => markSeen(tour.id, tour.version),
});
d.drive();
}, [tour, markSeen]);
useEffect(() => {
if (!tour || !ready || autoStarted.current) return;
if (isSeen(tour.id, tour.version)) return;
autoStarted.current = true;
// یک فریم صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
const t = window.setTimeout(start, 300);
return () => window.clearTimeout(t);
}, [tour, ready, isSeen, start]);
return { available: tour !== null, start };
}
```
نکته: `autoStarted` جلوی اجرای دوبارهٔ تور در رندرهای بعدی همان صفحه را می‌گیرد.
**نحوه تست:** تست کامپوننتی با mock کردن ماژول `driver.js`:
با تور دیده‌نشده و `ready: true`، بعد از پیش‌رفتن تایمر، `drive()` صدا زده می‌شود؛
با تور دیده‌شده صدا زده نمی‌شود؛ با `tourId` ناشناس هم صدا زده نمی‌شود.
---
### ۶. دکمهٔ راهنما
`assets/admin/components/ui/TourButton.tsx`:
```tsx
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
import { useTour } from '../../hooks/useTour';
/** دکمهٔ «؟» صفحه. اگر برای این صفحه توری ثبت نشده باشد، چیزی رندر نمی‌کند. */
export default function TourButton({ tourId }: { tourId?: string }) {
const { available, start } = useTour(tourId, { ready: false });
if (!available) return null;
return (
<button
type="button"
aria-label="راهنمای این صفحه"
title="راهنمای این صفحه"
onClick={start}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 'var(--r-pill)',
background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-3)',
}}
>
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
</button>
);
}
```
مهم: در `TourButton` مقدار `ready: false` داده می‌شود تا **دکمه** مسئول اجرای خودکار نباشد.
اجرای خودکار وظیفهٔ خودِ صفحه است که می‌داند داده‌اش کی آماده است.
`PageHeader` یک پراپ اختیاری می‌گیرد و دکمه را کنار عنوان می‌گذارد:
```tsx
interface Props {
title: string;
breadcrumbs?: Crumb[];
action?: React.ReactNode;
description?: string;
backTo?: string;
/** شناسهٔ تور راهنمای این صفحه؛ اگر ثبت نشده باشد دکمه‌ای نمی‌آید */
tourId?: string;
}
// ...
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<h1 className="section-title">{title}</h1>
<TourButton tourId={tourId} />
</div>
```
`action` دست‌نخورده می‌ماند.
**نحوه تست:** `components/ui/TourButton.test.tsx` — با `tourId="appointments"` دکمه با `aria-label` «راهنمای این صفحه» رندر می‌شود؛ با `tourId="nope"` و بدون `tourId` هیچ دکمه‌ای رندر نمی‌شود.
---
### ۷. استایل popover با توکن‌های پروژه
در `assets/admin/styles.css` بعد از توکن‌ها:
```css
/* تور راهنما — ظاهر driver.js با توکن‌های پنل یکی می‌شود (روشن و تیره) */
.driver-popover.cp-tour {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--r);
box-shadow: var(--shadow-lg);
font-family: inherit;
max-width: 320px;
}
.driver-popover.cp-tour .driver-popover-title { color: var(--text); font-size: 14px; font-weight: 700; }
.driver-popover.cp-tour .driver-popover-description { color: var(--text-2); font-size: 13px; line-height: 1.9; }
.driver-popover.cp-tour .driver-popover-progress-text { color: var(--text-3); font-size: 12px; }
.driver-popover.cp-tour .driver-popover-navigation-btns button {
background: var(--surface-2); color: var(--text-2);
border: 1px solid var(--border); border-radius: var(--r-sm);
font-family: inherit; font-size: 12px; text-shadow: none;
}
.driver-popover.cp-tour .driver-popover-navigation-btns button:last-child {
background: var(--primary); color: var(--on-primary); border-color: var(--primary);
}
.driver-popover.cp-tour .driver-popover-arrow-side-top { border-top-color: var(--surface); }
.driver-popover.cp-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--surface); }
.driver-popover.cp-tour .driver-popover-arrow-side-left { border-left-color: var(--surface); }
.driver-popover.cp-tour .driver-popover-arrow-side-right { border-right-color: var(--surface); }
```
**نحوه تست:** چشمی. یک‌بار در تم روشن و یک‌بار در تم تیره تور را اجرا کن. متن و دکمه‌ها باید خوانا باشند و رنگ دکمهٔ آخر همان رنگ برند باشد.
---
### ۸. اتصال به صفحهٔ نوبت‌ها
در `pages/AppointmentsPage.tsx`:
اجرای خودکار وقتی دادهٔ صفحه آمد:
```tsx
const { start: startTour } = useTour('appointments', { ready: !isLoading });
```
`isLoading` را از همان `useQuery` نوبت‌های صفحه بگیر؛ اسم متغیر واقعی را از کد بردار، نگذار حدس زده شود.
عنوان صفحه دکمهٔ راهنما بگیرد:
```tsx
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
<TourButton tourId="appointments" />
</div>
```
اتریبیوت‌ها روی همان hostهای موجود، بدون تغییر در کامپوننت‌های فرزند:
```tsx
<div data-tour="appointments-stats"><TurnsStatInfo stats={stats} /></div>
<div data-tour="appointments-date"><DateNavigator date={selectedDate} onChange={setSelectedDate} /></div>
<div data-tour="appointments-service"><ServiceFilterSelect ... /></div>
<div data-tour="appointments-view"><TurnsViewToggle viewMode={viewMode} onChange={setViewMode} /></div>
<button data-tour="appointments-filters" aria-label="فیلترها" ... />
<button data-tour="appointments-new" className="btn primary sm" ... />
```
روی کارت اصلی `data-tour="appointments-list"` و روی بلوک `showDoctorTabs` مقدار `data-tour="appointments-doctors"`.
قید مهم: `TurnsStatInfo`، `TurnsViewToggle`، `DoctorTabs` و `ServiceFilterSelect` **تغییر نکنند**.
فقط دور آن‌ها یک `div` با `data-tour` گذاشته شود.
دلیلش این است که این کامپوننت‌ها جای دیگری هم استفاده می‌شوند و تور نباید داخلشان نشت کند.
مواظب باش `div` اضافه چیدمان `flex` نوار ابزار را نشکند؛ اگر شکست، `display: 'contents'` روی wrapper بگذار
یا `data-tour` را مستقیم روی ریشهٔ همان کامپوننت از طریق پراپ عبور بده — گزینهٔ دوم فقط اگر گزینهٔ اول جواب نداد.
**نحوه تست:**
```bash
ddev exec npx tsc --noEmit --project tsconfig.json
ddev exec yarn test
ddev exec yarn dev
```
بعد ورود دستی با `09390039833 / 09390039833` و باز کردن `/admin/appointments`.
سناریوهای بخش «معیار پذیرش» یکی‌یکی چک شوند.
برای تست دوبارهٔ اجرای خودکار، در کنسول مرورگر:
```js
localStorage.removeItem('clinicpro-tours'); location.reload();
```
---
## نکات مهم
- محتوای تور فقط **داده** است، در `lib/tour/tours/*.ts`. هیچ متن راهنمایی داخل JSX صفحات نوشته نشود. هدف این است که فاز ۲ برای هر صفحه فقط «یک فایل تور + چند `data-tour` + یک `tourId`» باشد.
- `resolveSteps` عمداً یک تابع خالص جداست تا بدون رندر کردن صفحه تست شود.
- استپ غایب = حذف بی‌صدا. هیچ استپی نباید «اجباری» باشد، چون همهٔ صفحات پنل نقش‌محورند.
- `version` تور دلیل وجودی دارد: متن راهنما که عوض شد، کاربر قدیمی هم باید یک‌بار ببیندش. بدون version هیچ‌وقت دوباره نمایش داده نمی‌شود.
- کلید `localStorage` جدید `clinicpro-tours` است. با `clinicpro-auth` و `clinicpro-ui` قاطی نشود.
- اجرای خودکار حتماً به `ready` گره بخورد. اگر قبل از آمدن داده اجرا شود، المان‌ها هنوز نیستند و تور خالی می‌ماند.
- خروجیِ این فاز باید همان تصمیمِ نهایی «نوع helper» باشد. اگر ظاهر یا لحن متن‌ها مطلوب نبود، فقط `tours/appointments.ts` و بلوک CSS عوض می‌شوند، نه معماری.
- طبق قاعدهٔ پروژه هیچ تسکی بدون تست موفق و خطا و مرزی تمام نیست. تست‌های بند ۲ و ۴ و ۵ و ۶ اجباری‌اند.
- این تغییر backend ندارد، پس `docs/api/` دست نمی‌خورد.
- فاز ۲ بعد از تأیید نوشته می‌شود: تکرار همین الگو روی بقیهٔ صفحات، با تکیه بر پراپ `tourId` در `PageHeader` که ۸۲ نقطهٔ استفاده دارد.
@@ -0,0 +1,569 @@
# راهبر اجرای تسک‌های موتور نوبت‌دهی — یک تسک در هر اجرا
## پروژه
`clinicpro` (بک‌اند + پنل ادمین)
پرامپت همتا برای سایت عمومی: `nobat724_front/.claude/prompt/booking-engine-task-00b-service-mode.md`
(همین راهبر وقتی به تسک ۰۰ب برسد، تحویلش می‌دهد و می‌ایستد.)
---
## زمینه
`docs/new_feture/taskes/` شانزده تسک برای پیاده‌سازی
[مستند موتور نوبت‌دهی](../../docs/new_feture/clinic-pro-mostanad-sade.md) دارد. هر تسک یک
پوشه با شش فایل است و **خودش یک پرامپت کامل است**: `task.md` (دامنه و معیار پذیرش)،
`architecture.md` (فایل‌ها و سرویس‌ها)، `database.md` (جداول و migration)،
`implementation_notes.md` (نکات و edge case و تست)، `checklist.md` (وضعیت)، و برای
تسک‌های پیچیده `user_flow.md`.
جمع زمان تخمینی ۲۱۴ تا ۲۵۶ ساعت است. هیچ اجرایی نمی‌تواند همه را در یک نشست تمام کند.
**پس این پرامپت خودش قابلیت نیست — راهبر است.** هر اجرا:
```
وضعیت را از checklist.md ها بخوان
→ تسک بعدی واجد شرایط را انتخاب کن
→ همان یک تسک را کامل انجام بده (پیاده‌سازی + تست + مستند + چک‌لیست)
→ commit + graphify update
→ بایست و بگو تسک بعدی چیست
```
همین پرامپت را دوباره اجرا کن تا تسک بعدی برود. تا وقتی همهٔ چک‌لیست‌ها ✅ نشده‌اند، کار
تمام نیست.
---
## مشکل / هدف
**هدف:** یک مسیر اجرای تکرارشونده و ایمن که شانزده تسک را به ترتیب وابستگی، یکی‌یکی و
بدون از دست دادن وضعیت پیش ببرد — طوری که هر اجرا مستقل باشد و قطع شدن وسط کار،
کار انجام‌شده را از بین نبرد.
**چرا این شکل و نه یک پرامپت غول:** وضعیت در `checklist.md` روی دیسک زندگی می‌کند، نه در
context. اجرای بعدی همان فایل را می‌خواند و می‌فهمد کجا مانده. هزینهٔ این طراحی یک
مرحلهٔ «خواندن وضعیت» در ابتدای هر اجراست؛ سودش این است که هیچ اجرایی به context اجرای
قبلی وابسته نیست.
---
## معیار پذیرش
معیار پذیرش این راهبر، **رفتار خودِ راهبر** است. معیار پذیرش هر تسک در `task.md` همان تسک
است و اینجا تکرار نمی‌شود.
-**موفق:** اجرا با شناسایی وضعیت شروع می‌شود (جدول ۱۶ تسک با ✅/🔄/⏳)، دقیقاً یک تسک
انتخاب می‌شود، همهٔ ردیف‌های `checklist.md` آن تسک به ✅ می‌رسند، `bin/phpunit` و
`--group=slot-mode-frozen` سبزند، `docs/api/*` به‌روز است، یک commit زده می‌شود،
`graphify update .` اجرا می‌شود، و اجرا با اعلام تسک بعدی تمام می‌شود.
-**موفق (ازسرگیری):** اجرای دوم روی همان مخزن، تسک تمام‌شده را **دوباره انجام نمی‌دهد**
و مستقیم سراغ تسک بعدی می‌رود.
-**موفق (نیمه‌کاره):** اگر چک‌لیستی ردیف 🔄 دارد، همان تسک ادامه داده می‌شود، نه تسک بعدی.
-**خطا:** اگر تسکی وابستگی‌اش ✅ نیست → اجرا شروع نمی‌شود؛ پیام روشن با نام تسک
پیش‌نیاز و توقف.
-**خطا:** اگر `--group=slot-mode-frozen` قرمز شد → **توقف کامل**، rollback تغییرات آن
مرحله، گزارش دقیق کدام fixture شکست. fixture هرگز به‌روز نمی‌شود.
-**خطا:** اگر تستی سبز نشد → تسک `completed` نمی‌شود؛ ردیف چک‌لیست 🔄 می‌ماند و اجرا
با گزارش خطا تمام می‌شود. تسک بعدی شروع نمی‌شود.
- ⚠️ **مرزی:** همهٔ ۱۶ چک‌لیست ✅ → پیام «همهٔ تسک‌ها تمام شده‌اند» و توقف بدون تغییر.
- ⚠️ **مرزی:** تسک انتخاب‌شده ۰۰ب است (پروژهٔ دیگر) → اجرا **کد نمی‌زند**؛ دستور اجرای
پرامپت `nobat724_front` را می‌دهد و می‌ایستد.
- ⚠️ **مرزی:** کاربر شمارهٔ تسک را صریح داد (`/run-prompt … --task=05`) → همان تسک، ولی
وابستگی‌ها همچنان بررسی می‌شوند و اگر ناقص بودند توقف.
- ⚠️ **مرزی:** ردیفی از چک‌لیست به دلیل موجه قابل انجام نیست → ⏳ می‌ماند **با دلیل مکتوب و
تسک مقصد** در ستون یادداشت. ⏳ بی‌دلیل = تسک تمام نشده.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `docs/new_feture/taskes/README.md` | فهرست ۱۶ تسک، وابستگی‌ها، ترتیب اجرا |
| `docs/new_feture/taskes/00-current-state-report.md` | تحلیل شکاف وضعیت فعلی در برابر مستند |
| `docs/new_feture/taskes/_shared/red-lines.md` | ⛔ قواعد قفل‌شده — منطق اسلاتی دست‌کاری نمی‌شود |
| `docs/new_feture/taskes/_shared/ui-conventions.md` | 🎨 توکن‌ها و کامپوننت‌های اجباری |
| `docs/new_feture/taskes/_shared/definition-of-done.md` | ☑️ نمادها و بازبینی پایانی |
| `docs/new_feture/taskes/task-XX-*/task.md` | دامنه و معیار پذیرش هر تسک |
| `docs/new_feture/taskes/task-XX-*/architecture.md` | فایل‌ها، سرویس‌ها، قواعد UI |
| `docs/new_feture/taskes/task-XX-*/database.md` | جداول، ایندکس، migration، backfill |
| `docs/new_feture/taskes/task-XX-*/implementation_notes.md` | edge case و فهرست تست |
| `docs/new_feture/taskes/task-XX-*/checklist.md` | **وضعیت پایدار — منبع حقیقت پیشرفت** |
| `docs/new_feture/taskes/task-XX-*/user_flow.md` | جریان کاربری (۰۰، ۰۰ب، ۰۵، ۰۶، ۰۷، ۱۲) |
| `CLAUDE.md` · `docs/architecture/tenancy.md` | قواعد ثابت پروژه |
---
## وضعیت فعلی
هر شانزده `checklist.md` ساخته شده و **همهٔ ردیف‌هایشان `⏳` است**. هیچ تسکی شروع نشده.
ساختار ثابت هر چک‌لیست:
```markdown
# چک‌لیست — تسک XX (عنوان)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
## ۰. خط سرخ
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
...
## ۱. بک‌اند
## ۲. دیتابیس
## ۳. UI
## ۴. تست
## ۵. مستندات
## ۶. بازبینی پایانی
```
نمادها (از `_shared/definition-of-done.md`):
| نماد | معنی | اجازهٔ باقی‌ماندن در پایان تسک |
|---|---|---|
| ✅ | انجام‌شده و تأییدشده | بله |
| 🔄 | در حال انجام | **نه** |
| ⏳ | انجام‌نشده | **نه** — مگر با دلیل مکتوب و تسک مقصد |
| ⚠️ | نیازمند بررسی یا تست | **نه** — باید تعیین تکلیف شود |
ترتیب و وابستگی (از `README.md`):
```
۰۰ ── ۰۰ب
├─ ۰۱ ─┬─ ۰۲ ── ۰۳ ─┐
│ └─ ۰۴ ── ۰۵ ─┴─ ۰۶ ── ۰۷ ─┬─ ۰۸ ─┬─ ۰۹ ── ۱۰
│ │ └─ ۱۱ ── ۱۲
│ ├─ ۱۳
│ └─ ۱۴
```
| تسک | پروژه | وابستگی |
|---|---|---|
| ۰۰ تکمیل نوبت‌دهی سرویسی | `clinicpro` | — |
| ۰۰ب سازگارسازی سایت | `nobat724_front` | ۰۰ |
| ۰۱ شعبه و اتاق | `clinicpro` | ۰۰ |
| ۰۲ منبع، مهارت، استخر | `clinicpro` | ۰۱ |
| ۰۳ تقویم منبع | `clinicpro` | ۰۱، ۰۲ |
| ۰۴ کاتالوگ خدمات v2 | `clinicpro` | ۰۱ |
| ۰۵ بخش‌های نوبت | `clinicpro` | ۰۲، ۰۴ |
| ۰۶ جستجوی وقت چندمنبعی | `clinicpro` | ۰۳، ۰۵ |
| ۰۷ رزرو موقت و ثبت | `clinicpro` | ۰۶ |
| ۰۸ قیمت و snapshot | `clinicpro` | ۰۴، ۰۷ |
| ۰۹ موتور قوانین | `clinicpro` | ۰۵، ۰۶، ۰۸ |
| ۱۰ فرم و sandbox قانون | `clinicpro` | ۰۹ |
| ۱۱ پکیج و دفتر اعتبار | `clinicpro` | ۰۸ |
| ۱۲ دوره درمان | `clinicpro` | ۰۷، ۱۱ |
| ۱۳ لغو، عدم حضور، انتظار | `clinicpro` | ۰۷ |
| ۱۴ رویدادها و بهره‌وری | `clinicpro` | ۰۷ |
---
## وظایف
> ⚠️ این هفت وظیفه **مراحل یک اجرا**ی راهبرند، نه هفت قابلیت مستقل. `todo` را از روی
> ردیف‌های `checklist.md` تسکِ انتخاب‌شده بساز، نه از روی این هفت مرحله.
### ۱. خواندن قواعد حاکم — پیش از هر چیز
سه سند را کامل بخوان و در همین اجرا اعمال کن:
```
docs/new_feture/taskes/_shared/red-lines.md
docs/new_feture/taskes/_shared/ui-conventions.md
docs/new_feture/taskes/_shared/definition-of-done.md
```
در تناقض با متن هر تسک، **این سه برنده‌اند**.
مهم‌ترین بندشان:
- ⛔ منطق اسلاتی (`booking_mode = 'slot'`) در **هیچ تسکی** دست‌کاری نمی‌شود. فهرست فایل‌ها و
متدهای قفل‌شده در `red-lines.md` است.
- 🎨 هر صفحه یا کامپوننت جدید عیناً با دیزاین‌سیستم موجود — توکن‌های `assets/admin/styles.css`،
کامپوننت‌های `assets/admin/components/ui/`، پنج قاعدهٔ غیرقابل‌مذاکره.
- ☑️ هیچ تسکی بدون تکمیل چک‌لیستش تمام نیست.
**نحوه تست:** بعد از خواندن، در گزارش شروع بنویس کدام سه سند خوانده شد و مهم‌ترین قید
مربوط به تسک انتخاب‌شده چیست (یک جمله).
---
### ۲. شناسایی وضعیت و انتخاب تسک
همهٔ چک‌لیست‌ها را بخوان و وضعیت هر تسک را از **ردیف‌هایش** استنتاج کن، نه از خط
«وضعیت کلی» (که ممکن است به‌روز نشده باشد):
```bash
# فهرست چک‌لیست‌ها به ترتیب
ls -1 docs/new_feture/taskes/*/checklist.md | sort
# شمارش نمادها per تسک — دیدِ سریع وضعیت
for f in docs/new_feture/taskes/*/checklist.md; do
echo "$(dirname "$f" | xargs basename): ✅=$(grep -c '| ✅ |' "$f") 🔄=$(grep -c '| 🔄 |' "$f") ⏳=$(grep -c '| ⏳ |' "$f") ⚠️=$(grep -c '| ⚠️ |' "$f")"
done
```
قاعدهٔ استنتاج:
```
تمام‌شده = هیچ ردیف 🔄 · هیچ ردیف ⚠️ · هیچ ردیف ⏳ بدون یادداشتِ دلیل
در جریان = حداقل یک ردیف 🔄 یا ⚠️
شروع نشده = همهٔ ردیف‌ها ⏳ و هیچ یادداشتی
```
قاعدهٔ انتخاب، به همین ترتیب:
1. اگر تسکی **در جریان** است → همان. (تسک نیمه‌کاره رها نمی‌شود.)
2. وگرنه اولین تسکِ **شروع نشده** که همهٔ وابستگی‌هایش **تمام‌شده** اند.
3. اگر کاربر `--task=XX` داد → همان، ولی وابستگی‌ها همچنان بررسی می‌شوند.
4. اگر همه تمام‌شده‌اند → پیام و توقف.
5. اگر تسک انتخاب‌شده وابستگی ناقص دارد → توقف با نام تسک پیش‌نیاز.
جدول وضعیت را به کاربر نشان بده و تسک انتخاب‌شده را اعلام کن:
```
📊 وضعیت تسک‌ها
۰۰ تکمیل سرویسی ✅ تمام‌شده
۰۰ب سازگارسازی سایت ✅ تمام‌شده
۰۱ شعبه و اتاق 🔄 در جریان (۱۲/۳۸)
۰۲ منبع و مهارت ⏳ شروع نشده
🎯 تسک انتخاب‌شده: ۰۱ — شعبه و اتاق (ادامهٔ کار نیمه‌کاره)
```
**نحوه تست:** دستور شمارش بالا را اجرا کن و خروجی واقعی‌اش را در گزارش بگذار. جدول
نمایش‌داده‌شده باید با آن خروجی بخواند.
---
### ۳. تحویل به پرامپت همتا اگر تسک ۰۰ب انتخاب شد
تسک ۰۰ب روی `nobat724_front` است و در این پروژه هیچ کدی ندارد.
اگر انتخاب ۰۰ب شد، **هیچ فایلی را تغییر نده** و این را چاپ کن و بایست:
```
🔀 تسک ۰۰ب روی پروژهٔ nobat724_front است.
اجرا کن: /run-prompt nobat724_front/.claude/prompt/booking-engine-task-00b-service-mode.md
بعد از تمام شدنش، همین راهبر را دوباره اجرا کن تا تسک ۰۱ برود.
```
**نحوه تست:** موقتاً همهٔ ردیف‌های `task-00-service-mode-completion/checklist.md` را ✅ کن،
راهبر را اجرا کن، و ببین همین پیام چاپ می‌شود و هیچ فایلی در `src/` عوض نمی‌شود.
بعد تغییر موقت را برگردان.
---
### ۴. اجرای تسک انتخاب‌شده — یکی، کامل
چهار (یا پنج) فایل تسک را **کامل** بخوان:
```
task-XX-*/task.md ← دامنه، endpoint ها، معیار پذیرش
task-XX-*/architecture.md ← فایل‌ها، سرویس‌ها، الگوها، قواعد UI
task-XX-*/database.md ← جداول، ایندکس، migration، backfill
task-XX-*/implementation_notes.md ← edge case ها، فهرست تست، تله‌های مشخص
task-XX-*/user_flow.md ← اگر وجود دارد
```
بعد کد واقعی مرتبط را بخوان — **هیچ فرضی از حافظه** (guidelines §۱).
سپس `todo` بساز: **یک آیتم به ازای هر ردیفِ غیر-✅ چک‌لیست**، مرتب بر اساس بخش
(۰ خط سرخ → ۱ بک‌اند → ۲ دیتابیس → ۳ UI → ۴ تست → ۵ مستندات → ۶ بازبینی پایانی).
اجرای هر ردیف با همان هفت‌مرحلهٔ `run-prompt`:
```
① تحلیل ② طراحی ③ پیاده‌سازی ④ تست ⑤ رفع خطا ⑥ مستندسازی ⑦ چک‌لیست + گزارش
```
قواعدی که در این مرحله شکستنی نیستند:
- **بخش ۰ چک‌لیست اول از همه.** خط سرخ‌ها پیش از هر کد بررسی می‌شوند. در تسک ۰۰ این یعنی
ساختن `SlotModeFrozenTest` و سه fixture **قبل** از هر تغییر — وگرنه snapshot وضعیت
تغییریافته گرفته می‌شود و چیزی را تضمین نمی‌کند.
- **یک ردیف در هر لحظه `in_progress`.**
- **ردیف فقط با هر سه ✅ می‌شود:** پیاده‌سازی + تست سبز + مستند به‌روز.
- **خطا = توقف کامل.** هیچ ردیفی روی خرابهٔ ردیف قبلی ساخته نمی‌شود.
- **کشف کار جدید** (باگ جانبی، وابستگی پنهان) → ردیف جدید به همان `checklist.md` **اضافه
کن** و به کاربر بگو. انجام بی‌صدا ممنوع.
**نحوه تست:** دستورهای هر پروژه بعد از هر ردیفِ منطق‌دار:
```bash
ddev exec php bin/console cache:clear
ddev exec php bin/console doctrine:migrations:diff --no-interaction # اگر entity عوض شد
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/phpunit tests/<Domain> # تست همان تسک
ddev exec php bin/phpunit --group=slot-mode-frozen # ⛔ بعد از هر ردیف
ddev exec php vendor/bin/phpstan analyse src/<Domain>
npx tsc --noEmit --project tsconfig.json # اگر tsx عوض شد
yarn dev # بررسی خطای build
yarn test
```
برای هر endpoint، تست واقعی با داده واقعی — نه فقط `php -l`. کاربر تست:
`09390039833 / 09390039833` (اگر کار نکرد، اعتبارهای per-نقش با `QaTest@1234`).
---
### ۵. به‌روزرسانی زندهٔ `checklist.md`
چک‌لیست **حافظهٔ بین‌اجرایی** است. اگر به‌روز نشود، اجرای بعدی کار انجام‌شده را دوباره
انجام می‌دهد.
قواعد نوشتن:
- ردیف را **همان لحظه** که شروع می‌کنی `⏳ → 🔄` کن، نه در پایان تسک
- تمام شد → `🔄 → ✅` با یادداشت کوتاه اگر چیزی برای گفتن هست (مسیر فایل، عدد اندازه‌گیری‌شده)
- شکست خورد یا بلاک شد → `⚠️` با **دلیل** در ستون یادداشت
- به تعویق افتاد → `⏳` با **دلیل + تسک مقصد** در یادداشت. `⏳` بی‌دلیل = تسک تمام نشده.
- خط سربرگ را هم به‌روز کن:
```markdown
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۳
```
پیش از رفتن به وظیفهٔ ۶، **بخش ۶ (یا ۷/۸ بسته به تسک) بازبینی پایانی** را ردیف‌به‌ردیف
اجرا کن. ردیف‌های مشترک همهٔ تسک‌ها:
```
همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل)
ddev exec php bin/phpunit کامل سبز
ddev exec php bin/phpunit --group=slot-mode-frozen سبز
ddev exec php vendor/bin/phpstan analyse بدون خطای جدید
npx tsc --noEmit بدون خطا
yarn test سبز
TenantSchemaCoverageTest + TenantLookupInventoryTest سبز
docs/api/* به‌روز شد
چک‌لیست UI کامل شد (اگر تسک صفحه دارد)
nobat724_front و clinic-pro-tauri دستی بررسی شدند
commit شد، سپس graphify update .
موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند
```
ردیف «دو کلاینت دیگر» در build هیچ‌کدام خطا نمی‌دهد — بررسی **فقط دستی** ممکن است
(guidelines §۳). نتیجه را با ذکر فایل‌های بررسی‌شده گزارش بده، نه با «بررسی شد».
**نحوه تست:** بعد از به‌روزرسانی، دستور شمارش وظیفهٔ ۲ را دوباره اجرا کن. تسک باید
`✅=<همه> 🔄=0 ⏳=0 ⚠️=0` بدهد.
---
### ۶. Commit و به‌روزرسانی گراف
**ترتیب مهم است:** اول commit، بعد `graphify update` — وگرنه گراف وضعیت کامیت‌نشده را
ایندکس می‌کند.
```bash
git add -A
git commit -m "$(cat <<'EOF'
feat(booking): <عنوان انگلیسی تسک>
<دو-سه خط: چه چیزی اضافه شد، چه چیزی عمداً دست‌نخورده ماند>
Task: docs/new_feture/taskes/task-XX-<name>/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
graphify update .
```
پیام کامیت انگلیسی (قاعدهٔ پروژه). خط `Slot-mode contract` اجباری است — رد مکتوبِ
اینکه خط سرخ رعایت شده.
اگر روی برنچ `main` هستی، **اول برنچ بساز**:
```bash
git checkout -b feat/booking-engine-task-XX
```
**نحوه تست:** `git log --oneline -1` و `git status` را در گزارش بگذار.
---
### ۷. گزارش پایانی و اعلام تسک بعدی
اجرا با این قالب تمام می‌شود و **تسک بعدی شروع نمی‌شود**:
```
✅ تسک XX — <عنوان> تمام شد
پیاده‌سازی
• <سه تا پنج خط: چه ساخته شد، با مسیر فایل>
تست
• bin/phpunit : <N> تست، سبز
• --group=slot-mode-frozen : سبز
• phpstan / tsc / yarn test : سبز
• تست‌های جدید این تسک : <فهرست کوتاه>
مستندات
• <فایل‌های docs/api/* و docs/architecture/* که عوض شدند>
خط سرخ
• منطق اسلاتی دست‌نخورده — <یک جمله شاهد، مثلاً: هیچ متد SlotCalculatorService عوض نشد>
کلاینت‌های دیگر
• nobat724_front : <چه بررسی شد و نتیجه>
• clinic-pro-tauri: <همان>
به تعویق افتاد (اگر هست)
• <ردیف> — دلیل: <…> — تسک مقصد: <XX>
commit: <hash کوتاه> · graphify: به‌روز شد
────────────────────────────────
🎯 تسک بعدی: XX — <عنوان> (وابستگی‌ها: ✅)
اجرا کن: /run-prompt clinicpro/.claude/prompt/booking-engine-task-runner.md
```
اگر تسک ناتمام ماند، به‌جای `✅` بنویس:
```
⛔ تسک XX ناتمام ماند
بلاک‌کننده: <چه چیزی و کجا>
ردیف‌های باقی: <فهرست با وضعیت>
چک‌لیست به‌روز شد؛ اجرای بعدی از همین‌جا ادامه می‌دهد.
```
**نحوه تست:** گزارش باید با خروجی واقعی دستورها بخواند. عدد تست ساختگی ننویس.
---
## نکات مهم
### خط سرخ — تنها چیزی که هیچ تسکی نمی‌تواند نقض کند
`booking_mode = 'slot'` منطق تولیدیِ زنده است. `_shared/red-lines.md` فهرست دقیق دارد؛
خلاصه‌اش:
| قفل | یعنی |
|---|---|
| هفت متد عمومی `SlotCalculatorService` | امضا و رفتار عوض نمی‌شود |
| `GET /api/v1/appointment-slots` | قرارداد request/response |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | قرارداد |
| `Appointment::active_slot_key` و `refreshActiveSlotKey()` | مکانیزم یکتایی موجود |
| `AppointmentRepository::isSlotTaken` | امضا و معنا |
| `WeeklySchedule::DEFAULT_META['booking_mode']` | مقدار `slot` می‌ماند |
**مجاز:** افزودن متد جدید · کلاس موازی · ستون تهی‌پذیر · کلید جدید در `meta` با حفظ پیش‌فرض
**ممنوع:** تغییر امضای متد موجود · refactor «یکدست‌سازی» · اعمال قوانین جدید روی حالت `slot`
fixture های `tests/Appointment/fixtures/slot-mode-contract.json` و
`month-availability-contract.json` و `slot-calculator-signatures.php` **read-only** اند.
اگر تست قرمز شد، **کد برمی‌گردد، نه fixture**. تسک ۰۰ آن‌ها را می‌سازد؛ هیچ تسک دیگری
اجازهٔ به‌روزرسانی‌شان را ندارد.
### فاز ۰ اختیاری نیست
تسک ۰۰ و ۰۰ب پیش‌نیاز واقعی‌اند، نه توصیه. حالت `service` امروز پنج شکاف در چرخهٔ عمر
نوبت دارد (ویرایش، جابه‌جایی، رزرو، پنل بیمار، دیزاین‌سیستم سایت — جزئیات در
`00-current-state-report.md` بخش ۲-۵ب). اگر حالت `resource` (تسک ۰۶) روی این پایه ساخته
شود، هر باگ موجود به موتور جدید ارث می‌رسد و تشخیص منبعش غیرممکن می‌شود.
### دیزاین‌سیستم — هر صفحهٔ جدید
`_shared/ui-conventions.md` کامل است. پنج قاعده‌ای که رعایت نکردنشان یعنی رد شدن ردیف:
1. `SearchableSelect`، هرگز `<select>` بومی
2. زیرصفحه‌ها `backTo` یا `<BackButton fallback="…" />` — دکمهٔ دست‌ساز نه
3. وضعیت لیست (جستجو/فیلتر/صفحه) در URL با `hooks/useUrlState.ts`، نه `useState`
4. داده فقط TanStack Query از `lib/api.ts`؛ استخراج: paginated → `data?.data` +
`data?.meta?.totalRecords` · single → `data?.data` · Category → `data?.data?.data ?? []`
5. فرم با React Hook Form + Zod
و: هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن‌های `:root` در `assets/admin/styles.css`.
دارک‌مود (`[data-theme="dark"]`) و حالت فشرده (`[data-density="compact"]`) بررسی شوند.
مقدار hex در دارک‌مود می‌شکند.
پیش از ساختن هر کامپوننت، `assets/admin/components/ui/` را بگرد. ساختن نسخهٔ موازیِ
`DataTable`/`Modal`/`ConfirmDialog`/`PageHeader`/`StatCard`/`StatusBadge`/`Pagination`/
`PersianDatePicker`/`PriceInput` رد می‌شود.
### قواعد ثابت پروژه در هر تسک
از `CLAUDE.md` و `docs/architecture/tenancy.md`:
- entity جدید یا `TenantOwnedTrait` می‌گیرد یا با دلیل در `App\Shared\Tenant\GlobalTables`
ثبت می‌شود؛ `TenantSchemaCoverageTest` هر دو حالت را اجبار می‌کند
- `entity_type, entity_id` **ستون‌های اول** هر ایندکس ترکیبیِ لیست — وگرنه MariaDB برای
شرط `TenantFilter` از ایندکس استفاده نمی‌کند
- هر uuid که از request می‌آید با `App\Shared\Tenant\TenantOwnershipChecker` سنجیده
می‌شود؛ `TenantLookupInventoryTest` شمارنده per-file دارد و `findByUuid` تازه تست را
قرمز می‌کند
- timestamp ها `int` یونیکس، نه `DateTime`؛ نمایش شمسی فقط در UI با `formatDate`
- کنترلر نازک · `extends BaseController` · `$this->success()/paginated()/error()`
- خطا با `throw new AppException(ErrorCodes::ERR_XXX, null, $status)`؛ کد و پیام فارسی در
`src/Shared/Constant/ErrorCodes.php`
- لیست‌های ادمین با `->getArrayResult()`
- **API جدید فقط وقتی هیچ endpoint موجودی — حتی با توسعه — کافی نباشد؛ دلیلش نوشته شود**
- هر endpoint جدید یا تغییریافته → `docs/api/*.md` در **همان** نشست
### الگوهای معماری که تسک‌ها تصریح کرده‌اند
| الگو | تسک | دلیل انتخاب (در همان تسک مکتوب است) |
|---|---|---|
| Strategy + tagged_iterator | ۰۶ `ResourcePickerInterface` | افزودن استراتژی پنجم نباید کلاس موجود را عوض کند (OCP) |
| Repository + سرویس واحدِ نویسنده | ۰۷ `OccupancyWriter` | تنها نقطهٔ نوشتن در `resource_occupancy` |
| Registry بسته | ۰۹ `FieldRegistry`/`OperatorRegistry`/`EffectRegistry` | مستند بند ۸: کد دلخواه در قانون ممنوع، وگرنه جستجوی وقت کند می‌شود |
| Outbox | ۱۴ `DomainEventPublisher` | commit اتمی رویداد با کار؛ نه «پیامک رفته و نوبت نیست» |
| Ledger (دفتر) نه شمارنده | ۱۱ `SessionCreditLedger` | مستند بند ۱۲: با شمارنده، اولین اشتباه غیرقابل‌ردیابی است |
| کلید یکتای سطل زمانی | ۰۷ `resource_occupancy_slot` | MariaDB `EXCLUDE` ندارد؛ تضمین از دیتابیس نه از کد |
این‌ها تصمیم گرفته‌شده‌اند، نه پیشنهاد. اگر در اجرا به مشکل خوردی، **قبل از تغییر
الگو** دلیل را به کاربر بگو و تعیین تکلیف بخواه (guidelines §۶).
### تسک‌های پرخطر — جای کندی و دقت
| تسک | ریسک | کاری که باید بکنی |
|---|---|---|
| ۰۰ | fixture بعد از تغییرات ساخته شود | fixture **اول**، بعد کد |
| ۰۶ | I/O داخل حلقه → جستجو کند | `AvailabilityPerformanceTest` تعداد کوئری را قفل می‌کند: ≤۵ |
| ۰۷ | تست همزمانی با mock بی‌ارزش است | دو اتصال DBAL واقعی، `assertSame(1, ok1+ok2)` |
| ۰۹ | `spacing` per-slot حلقه بزند | `forbiddenRanges()` کوئری‌محور؛ `AvailabilityPerformanceTest` باید با قوانین فعال هم سبز بماند |
| ۱۰ | شبیه‌سازی داده واقعی بنویسد | rollback در `finally` + `em->clear()` + تست شمارش ردیف |
| ۱۱ | `quote` اعتبار مصرف کند | مصرف **فقط** در `confirm`؛ تست: ده `quote` → مانده بی‌تغییر |
| ۱۲ | `book-all` نیمه‌کاره | همه یا هیچ در یک تراکنش |
### cross-repo — بررسی دستی اجباری
`nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` هر دو
مصرف‌کنندهٔ همان `/api/v1/...` اند. تغییر قرارداد در build هیچ‌کدام خطا نمی‌دهد و در
runtime می‌شکند (guidelines §۳).
تسک‌هایی که قرارداد را واقعاً عوض می‌کنند و بررسی دستی‌شان بحرانی است:
- **۰۰** — `service_item` تکی باید با `service_items` هم‌گام بماند (چهار مصرف‌کننده رویش خوانده‌اند)
- **۰۴** — مدت نوبت‌های چندسرویسی **عوض می‌شود** (فرمول «زمان تنها/زمان اضافه»)
- **۰۶** — پس از ارتقای یک کلینیک به `resource`، کلاینت باید مسیر جدید صدا بزند
- **۰۸** — مبلغ نمایشی رزرو ممکن است عوض شود
- **۱۳** — سایت باید `cancellation-preview` را نشان دهد
در گزارش هر تسک، **فایل‌های بررسی‌شده را نام ببر**؛ «بررسی شد» بی‌ارزش است.
### وقتی متن تسک با کد واقعی نمی‌خواند
تسک‌ها از خواندن کد در تاریخ نوشتنشان ساخته شده‌اند. اگر جایی متن با کد امروز نخواند:
1. **کد واقعی مرجع است**، نه متن تسک
2. اختلاف را به کاربر بگو
3. متن تسک را اصلاح کن (فایل تسک هم سند است — guidelines §۴)
4. بعد پیاده‌سازی کن
موارد شناخته‌شده‌ای که تسک‌ها خودشان علامت زده‌اند و باید **پیش از کدنویسی** تأیید شوند:
- تسک ۱۴ ردیف ۲.۹ — وجود `patient_sessions.started_at/ended_at`؛ اگر نبود، گزارش
`plan-accuracy` به تسک جدا موکول می‌شود، **نه** ساخته شدن با داده حدسی
- تسک ۰۰ب بخش ۱ — وجود `service_items` و `service_total_minutes` در پاسخ
`GET /api/v1/appointments/user`؛ اگر نبود، به تسک ۰۰ برمی‌گردد
- تسک ۰۷ database.md — وابستگی معکوس: جدول `resource_occupancy` در تسک **۰۶** migrate
می‌شود، تعریفش در ۰۷ است
@@ -0,0 +1,307 @@
# جستجوی پزشک روی همهٔ تخصص‌ها و برگرداندن ساختار والد/فرزند
## پروژه
`clinicpro` — بک‌اند Symfony.
**cross-repo.** قرارداد `GET /api/v1/doctors` را سایت عمومی مصرف می‌کند.
پرامپت همتا: `nobat724_front/.claude/prompt/doctor-multi-specialty-ui.md`.
آن را **بعد از** این یکی اجرا کن — کارت پزشک به `parent_id` در پاسخ همین اندپوینت نیاز دارد.
## زمینه
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب،
شناسهٔ ۱۴۹۹۲:
```
13 جراحی عمومی (والد)
14 جراحی پلاستیک و زیبایی
167 جراحی لاپاراسکوپی
168 جراح تیروئید
169 جراح گوارش
170 جراحی سرطانها
```
هنگام ذخیره، `expandWithAncestors` والدها را خودکار اضافه می‌کند، پس والد معمولاً روی پزشک نشسته است.
## مشکل / هدف
سه شکاف در بک‌اند:
۱. **جستجوی متنی تخصص را نمی‌بیند.** تایپ «جراح گوارش» در کادر جستجو هیچ پزشکی برنمی‌گرداند،
چون فقط `d.name` گشته می‌شود.
۲. **فیلتر `specialty_id` به فرزندان گسترش نمی‌یابد.** امروز فقط به‌خاطر عارضهٔ جانبیِ
`expandWithAncestors` در زمان ذخیره کار می‌کند. هر پزشکی که از مسیر دیگری وارد شود
(مثلاً import دسته‌ای) این تضمین را ندارد. تکیهٔ یک قابلیت جستجو بر یک side effect ذخیره، شکننده است.
۳. **پاسخ لیست `parent_id` ندارد.** کارت پزشک در سایت عمومی نمی‌تواند تشخیص دهد کدام تخصص
والد است، پس نمی‌تواند «تخصص اصلی + N» بسازد.
## معیار پذیرش
- ✅ موفق: `GET /api/v1/doctors?specialty_id=13` پزشکی را که **فقط** `169` (جراح گوارش) دارد و
والد روی او ثبت نشده هم برمی‌گرداند.
- ✅ موفق: `GET /api/v1/doctors?name=جراح گوارش` دکتر جهانتاب را برمی‌گرداند.
- ✅ موفق: `GET /api/v1/doctors?name=جهانتاب` همچنان همان پزشک را برمی‌گرداند — تطابق نام نشکسته.
- ✅ موفق: هر آیتم پاسخ، در `specialties[]` کلید `parent_id` دارد؛ برای ریشه `null`.
- ❌ خطا: `specialty_id` با شناسهٔ ناموجود → `200` و آرایهٔ خالی، نه `500`.
- ❌ خطا: `name` با عبارتی که به هیچ پزشک و هیچ تخصصی نمی‌خورد → `200` و `totalRecords: 0`.
- ⚠️ مرزی: **`specialty_id` و `name` با هم**. `?specialty_id=13&name=جهانتاب` باید همان پزشک را
بدهد. این حالت با پیاده‌سازی ساده می‌شکند — پایین توضیح داده شده.
- ⚠️ مرزی: تخصص برگ (بدون فرزند) — `?specialty_id=169` فقط پزشکان همان تخصص، نه کل گروه.
- ⚠️ مرزی: `totalRecords` باید با تعداد ردیف‌های واقعی بخواند. join چندبه‌چند بدون `DISTINCT`
در شمارش، پزشک چندتخصصی را چند بار می‌شمارد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Specialty/Repository/SpecialtyRepository.php` | `parentMap()` و `expandWithAncestors()` موجود |
| `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — همهٔ فیلترهای لیست عمومی |
| `src/Doctor/Entity/Doctor.php` | `toListArray()` — شکل آیتم لیست |
| `docs/api/doctor.md` | سند اندپوینت |
| `docs/api/specialty.md` | سند تخصص |
## وضعیت فعلی
### فیلترها — تک‌شناسه و فقط نام پزشک
```php
// src/Doctor/Repository/DoctorRepository.php:51
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
->distinct();
```
```php
// همان فایل:76
if (!empty($filters['specialty_id'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
}
```
```php
// همان فایل:89
if (!empty($filters['name'])) {
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
}
```
### گسترش به بالا وجود دارد، به پایین نه
```php
// src/Specialty/Repository/SpecialtyRepository.php:75
public function expandWithAncestors(array $ids): array
{
$map = $this->parentMap();
$out = [];
foreach ($ids as $id) {
$cur = (int) $id;
$seen = [];
while (array_key_exists($cur, $map) && !isset($seen[$cur])) {
$seen[$cur] = true;
$out[$cur] = true;
$cur = $map[$cur] ?? 0;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
/** @return array<int,?int> id => parentId for every specialty */
private function parentMap(): array
{
if ($this->parentMap === null) {
$rows = $this->createQueryBuilder('s')
->select('s.id AS id', 'IDENTITY(s.parent) AS parent')
->getQuery()
->getArrayResult();
$this->parentMap = [];
foreach ($rows as $row) {
$this->parentMap[(int) $row['id']] = $row['parent'] !== null ? (int) $row['parent'] : null;
}
}
return $this->parentMap;
}
```
### پاسخ لیست — بدون parent_id
```php
// src/Doctor/Entity/Doctor.php:578
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
], $this->specialties->toArray()),
```
## وظایف
### ۱. `expandWithDescendants` در `SpecialtyRepository`
قرینهٔ `expandWithAncestors`. از همان `parentMap()` کش‌شده استفاده می‌کند، پس کوئری اضافه ندارد.
درخت امروز دقیقاً دو سطح است — شمارش نوه‌ها صفر است — ولی مثل قرینه‌اش **generic** بنویس،
نه یک‌سطحی. عمقِ فرضی، بدهیِ فردا است.
```php
/**
* شناسه‌های داده‌شده به‌علاوهٔ همهٔ نوادگانشان.
*
* قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای
* «این گروه یعنی همهٔ زیرشاخه‌هایش هم».
*
* @param list<int|string> $ids
* @return list<int>
*/
public function expandWithDescendants(array $ids): array
{
$map = $this->parentMap();
$children = [];
foreach ($map as $id => $parent) {
if ($parent !== null) {
$children[$parent][] = $id;
}
}
$out = [];
$queue = array_map('intval', $ids);
while ($queue) {
$cur = array_pop($queue);
if (isset($out[$cur])) {
continue;
}
$out[$cur] = true;
foreach ($children[$cur] ?? [] as $child) {
$queue[] = $child;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
```
`isset($out[$cur])` هم dedupe است و هم محافظ حلقه، اگر داده‌ای چرخه بسازد.
**نحوه تست:** unit test در `tests/Specialty/SpecialtyDescendantsTest.php`
`expandWithDescendants([13])` باید `13` و همهٔ فرزندانش را بدهد؛
`expandWithDescendants([169])` فقط `[169]`؛ آرایهٔ خالی → آرایهٔ خالی؛
شناسهٔ ناموجود → همان شناسه بدون خطا.
### ۲. فیلتر `specialty_id` به نوادگان گسترش یابد
**دقت کن — اینجا یک تلهٔ واقعی هست.** alias فعلی `s` هم برای فیلتر تخصص استفاده می‌شود و هم
(در وظیفهٔ ۳) برای جستجوی نام تخصص. اگر هر دو روی همان alias بنشینند، DQL مجبور می‌شود
**یک ردیفِ join** هر دو شرط را با هم ارضا کند. پزشکی که با تخصص A فیلتر را پاس می‌کند و
نامِ تخصص B را دارد، حذف می‌شود.
پس هر دو فیلتر را با زیرکوئری `EXISTS` بنویس و join اصلی `s` را دست نزن — آن فقط برای
hydration است.
```php
if (!empty($filters['specialty_id'])) {
$ids = $this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']]);
$qb->andWhere(
$qb->expr()->exists(
'SELECT sf.id FROM ' . Specialty::class . ' sf'
. ' WHERE sf MEMBER OF d.specialties AND sf.id IN (:specialtyIds)'
)
)->setParameter('specialtyIds', $ids);
}
```
`SpecialtyRepository` را با constructor injection بگیر، `new` نکن.
**نحوه تست:** تست فانکشنال در `tests/Doctor/DoctorSpecialtySearchTest.php`.
پزشکی بساز که **فقط** یک زیرتخصص دارد و والد روی او ثبت **نیست** (مستقیم با
`$doctor->getSpecialties()->add($child)` و flush، نه از راه اندپوینت که والد را اضافه می‌کند).
سپس `GET /api/v1/doctors?specialty_id=<parentId>` باید او را برگرداند.
همچنین `?specialty_id=<leafId>` نباید پزشکِ یک زیرتخصصِ خواهر را برگرداند.
### ۳. `name` نام تخصص را هم بگردد
```php
if (!empty($filters['name'])) {
$qb->andWhere(
$qb->expr()->orX(
'd.name LIKE :name',
$qb->expr()->exists(
'SELECT sn.id FROM ' . Specialty::class . ' sn'
. ' WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name'
)
)
)->setParameter('name', '%' . $filters['name'] . '%');
}
```
ترتیب نتایج را عوض نکن. مرتب‌سازی فعلی سر جایش می‌ماند؛ اگر بعداً «نام پزشک اول» خواسته شد،
تسک جداست.
**نحوه تست:** در همان تست فانکشنال —
`?name=<نام تخصص>` پزشک را می‌دهد؛ `?name=<بخشی از نام پزشک>` همچنان می‌دهد؛
`?specialty_id=<parentId>&name=<نام پزشک>` هم می‌دهد (همان حالت مرزی که با alias مشترک می‌شکست)؛
عبارت بی‌ربط → `totalRecords: 0`.
### ۴. `parent_id` در پاسخ لیست
```php
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
// کلاینت بدون این نمی‌تواند «تخصص اصلی» را از زیرتخصص تشخیص دهد.
'parent_id' => $s->getParent()?->getId(),
], $this->specialties->toArray()),
```
افزودنی است و هیچ کلید موجودی را عوض نمی‌کند.
**مراقب N+1 باش.** `getParent()` روی proxy یعنی یک کوئری به‌ازای هر تخصص به‌ازای هر پزشک.
در `findWithFilters` روی همان join موجود `addSelect('s')` بگذار و والد را هم join و select کن.
با تست شمارش کوئری تثبیتش کن — `ApiTestCase::countQueries()` برای همین هست.
**نحوه تست:** `GET /api/v1/doctors?limit=10` و بررسی اینکه هر آیتم `parent_id` دارد و برای
ریشه `null` است. سپس `countQueries()` دور همان فراخوانی: تعداد کوئری با ۱۰ پزشک نباید
به‌طور معنادار از حالت ۱ پزشک بیشتر باشد.
### ۵. مستندات
`docs/api/doctor.md` — بخش `GET /api/v1/doctors`:
- `specialty_id` حالا «این تخصص و همهٔ زیرشاخه‌هایش» است. صریح بنویس، چون معنایش عوض شده.
- `name` حالا نام پزشک **یا** نام هر یک از تخصص‌هایش را می‌گردد.
- شکل `specialties[]` با `parent_id` تازه، و **JSON واقعی از اجرای واقعی** نه دست‌ساز.
`docs/api/specialty.md` — یادآوری کن که `GET /api/v1/specialties` بدون `parent_id` همهٔ
تخصص‌های فعال را با `parent_id` و `slug` می‌دهد و **سایت عمومی از همین برای ساخت
`data/specialties.json` در زمان build استفاده می‌کند**. اگر شکل این پاسخ عوض شود، آن اسکریپت می‌شکند.
## نکات مهم
- **تلهٔ alias مشترک، مهم‌ترین نکتهٔ این تسک است.** اگر وظیفهٔ ۲ و ۳ هر دو روی `s` بنویسند،
تست‌های تکی سبز می‌شوند و فقط حالت ترکیبیِ `specialty_id` و `name` می‌شکند — یعنی همان چیزی
که کاربر در فیلترها با هم می‌زند.
- **`DISTINCT` و شمارش.** `findWithFilters` الان `->distinct()` دارد. مطمئن شو کوئری شمارش هم
همان تمایز را دارد، وگرنه `totalRecords` برای پزشک چندتخصصی باد می‌کند و صفحه‌بندی می‌شکند.
- **این تغییر هیچ فیلتری را تنگ نمی‌کند، فقط باز می‌کند.** اگر تستی از رفتار فعلی لیست عمومی
شکست، یعنی زیرکوئری اشتباه بسته شده.
- **`expandWithAncestors` را دست نزن.** آن در `hydrateDoctor` و `RepresentationActionController`
استفاده می‌شود و کارِ دیگری می‌کند. دو تابع، دو جهت.
- **Repository نازک، Controller نازک‌تر.** منطق گسترش در `SpecialtyRepository` می‌ماند، نه در
`DoctorRepository` و نه در کنترلر.
- **cross-repo:** بعد از این تغییر، `nobat724_front` باید دستی بررسی شود. تغییر شکل پاسخ در
build آن خطا نمی‌دهد و فقط در رانتایم دیده می‌شود. صفحهٔ `/doctors` و `/specialties/[slug]`
را باز کن و مطمئن شو چیزی نشکسته.
- دیتابیس تست هرگز reset نمی‌شود؛ دادهٔ هر تست را با `uniqid()` یکتا بساز تا اجرای دوم هم سبز بماند.
@@ -0,0 +1,366 @@
# رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوت‌شده
## پروژه
`clinicpro` (backend + پنل ادمین). cross-repo نیست.
## زمینه
دو نقش در یک کلینیک مجوز per-resource دارند: **منشی** و **پزشکِ دعوت‌شده**. هر کدام
جدول مجوز خودش را دارد و هر کدام یک فهرست جدا از «منابع» در کد.
امروز آن فهرست **چهار بار** نوشته شده و هیچ‌کدام از هم خبر ندارند:
| کجا | چه چیزی |
|---|---|
| `src/Secretary/Entity/DoctorSecretary.php:26` | `DEFAULT_PERMISSIONS` — ۱۵ منبع |
| `src/Clinic/Entity/ClinicDoctorPermission.php:21` | `DEFAULT_PERMISSIONS` — ۱۳ منبع |
| `assets/admin/pages/MySecretariesPage.tsx:70` | `EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS` |
| `assets/admin/components/ui/DoctorPermissionsModal.tsx:24` | `RESOURCE_LABELS` |
نتیجه‌اش را می‌شود در خودِ کد دید.
## مشکل / هدف
**۱. دو فهرست backend با هم فرق دارند.**
منشی این دو را دارد و پزشک ندارد: `clinic_doctors`، `subscription`.
و برای `services`:
```php
// DoctorSecretary
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
// ClinicDoctorPermission
'services' => ['view' => true, 'update' => false],
```
یعنی همان منبع در دو نقش دو مجموعهٔ action دارد. `ClinicDoctorPermission::apply()`
هر actionی را که در `DEFAULT_PERMISSIONS` نباشد دور می‌اندازد، پس `create` و `delete`
برای پزشک اصلاً قابل ذخیره نیست.
**۲. صفحه‌های تازه، مجوزِ صفحهٔ دیگری را قرض می‌گیرند.**
از ۸۴ route، ۳۲ تا گیت مجوز دارند. ولی پنج صفحهٔ متفاوت روی **یک** منبع نشسته‌اند:
```
resources · resource-types · catalog-categories · skills · resource-pools
→ همه: permission={['appointment_settings', 'view']}
treatment-cases
→ permission={['appointments', 'view']}
```
هیچ‌کدام از این‌ها واقعاً «تنظیمات نوبت‌دهی» یا «نوبت‌ها» نیستند. دلیلش روشن است:
افزودن یک منبع تازه یعنی ویرایش دستیِ چهار فهرست، پس هرکس نزدیک‌ترین منبع موجود را
برداشته. مجوزها از ساختار صفحه‌ها جدا افتاده‌اند.
**۳. جواب سؤال «آیا می‌شود داینامیک باشد؟» بله است.**
یک رجیستری در PHP به‌عنوان منبع واحد، یک اندپوینت که آن را می‌دهد، و دو UI که به‌جای
فهرست هاردکد از همان می‌خوانند. صفحهٔ تازه = یک ردیف در رجیستری، نه چهار ویرایش.
---
## ⚠ دو تصمیم که باید قبل از کد روشن باشد
### الف) «دقیقاً شبیه پزشک» یعنی فهرست یکی شود، نه پیش‌فرض‌ها
**فهرستِ منابع و actionها** برای هر دو نقش یکی می‌شود — این خواستهٔ روشنِ کاربر است.
**مقادیر پیش‌فرض** یکی نمی‌شوند و نباید بشوند: پزشکِ عضو کلینیک به‌طور طبیعی از منشی
دسترسی بیشتری دارد (مثلاً `patients.update` پیش‌فرضش `true` است و برای منشی `false`).
یکی کردنشان یعنی یا منشی بیش از حد باز شود یا پزشک بی‌جهت بسته.
پس رجیستری **شکل** را می‌دهد و هر نقش **پیش‌فرضِ خودش** را.
اگر منظور کاربر این نبوده و واقعاً می‌خواهد پیش‌فرض‌ها هم یکی شود، قبل از پیاده‌سازی
بپرس — این تغییر روی همهٔ منشی‌های موجود اثر می‌گذارد.
### ب) دادهٔ ذخیره‌شده نباید پاک شود
هر دو جدول ستون `permission` از نوع `json` دارند و مقدارِ فعلیِ منشی‌ها و پزشک‌ها
داخلش است. افزودن منبع تازه به رجیستری **نباید** مقدار ذخیره‌شده را بازنویسی کند.
قاعده: خواندن = merge رجیستری با مقدارِ ذخیره‌شده؛ کلیدِ نبوده از پیش‌فرضِ رجیستری
پر می‌شود. **migration دادهٔ انبوه لازم نیست** و نباید نوشته شود.
---
## معیار پذیرش
- ✅ موفق: `GET /api/v1/permission-catalog` با توکن مالک کلینیک → ۲۰۰ و فهرست همهٔ
منابع با actionها و برچسب فارسی. صفحهٔ `/admin/my-secretaries` و مودال مجوز پزشک
هر دو از همین پاسخ رندر می‌شوند و هیچ فهرست هاردکدی ندارند.
- ✅ موفق: افزودن یک منبع تازه به رجیستری (مثلاً `treatment`) → بدون هیچ تغییر دیگری
در فرانت، هم در فرم منشی و هم در مودال پزشک ظاهر می‌شود.
- ✅ موفق: منشیِ بدون `treatment.view` وارد `/admin/treatment-cases` شود → به داشبورد
برگردانده شود (`RoleRoute`)، و `GET /api/v1/treatment-cases` برایش ۴۰۳ بدهد.
- ❌ خطا: `PUT` مجوز با منبعِ ناشناخته یا actionِ ناشناخته → ۴۲۲، و مقدار قبلی
دست‌نخورده بماند.
- ⚠️ مرزی: منشیِ ساخته‌شده **قبل** از این تغییر که `treatment` در JSONش نیست →
خواندنش خطا ندهد و آن منبع با پیش‌فرضِ رجیستری برگردد، نه `null`.
- ⚠️ مرزی: مالک کلینیک و ادمین همیشه مجازند — رجیستری نباید این را عوض کند
(`ClinicDoctorPermissionChecker::can()` خط ۲۶).
- ⚠️ مرزی: محیط شخصیِ پزشک `permissions` ندارد؛ `usePermissions` نبودش را «محدودیتی
نیست» می‌خواند و این باید همان بماند.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` منشی |
| `src/Clinic/Entity/ClinicDoctorPermission.php` | `DEFAULT_PERMISSIONS` پزشک + `apply()` که actionِ ناشناخته را دور می‌ریزد |
| `src/Secretary/Security/SecretaryPermissionChecker.php` | بررسی مجوز منشی |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | بررسی مجوز پزشک |
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز منشی |
| `assets/admin/components/ui/DoctorPermissionsModal.tsx` | مودال مجوز پزشک |
| `assets/admin/hooks/usePermissions.ts` | `can(resource, action)` در فرانت |
| `assets/admin/App.tsx` | گیتِ route با `permission={[resource, action]}` |
| `docs/api/secretary.md` · `docs/api/clinic.md` | سند اندپوینت‌ها |
---
## وضعیت فعلی
### رجیستری منشی
```php
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
// …
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'subscription' => ['view' => false, 'create' => false],
],
];
```
### رجیستری پزشک — دو منبع کمتر، و `services` با actionهای متفاوت
```php
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
'services' => ['view' => true, 'update' => false],
// clinic_doctors و subscription اصلاً نیستند
],
];
```
### فرانت — همان فهرست، بار سوم و چهارم
```tsx
// DoctorPermissionsModal.tsx
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
appointments: {
label: 'نوبت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
},
// …
};
```
```tsx
// MySecretariesPage.tsx
const EMPTY_PERMISSIONS: SecretaryPermissions = {
appointments: { view: false, create: false, cancel: false, update_status: false },
// …
};
```
### گیتِ فعلیِ صفحه‌های تازه
```tsx
<Route path="resources" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="treatment-cases" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointments', 'view']}><TreatmentCasesPage /></RoleRoute>} />
```
---
## وظایف
### ۱. رجیستری منابع — منبع واحد
`src/Shared/Security/PermissionCatalog.php`
یک کلاس با یک ثابت: هر منبع، actionهایش، و برچسب فارسی. **هیچ پیش‌فرضی اینجا نیست**
اینجا فقط «چه چیزهایی وجود دارند».
```php
final class PermissionCatalog
{
/** @var array<string, array{label: string, actions: array<string, string>}> */
public const RESOURCES = [
'appointments' => [
'label' => 'نوبت‌ها',
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'cancel' => 'لغو', 'update_status' => 'تغییر وضعیت'],
],
// …
'resources' => [
'label' => 'منابع و دستگاه‌ها',
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'update' => 'ویرایش', 'delete' => 'حذف'],
],
'treatment' => [
'label' => 'درمان‌های چندجلسه‌ای',
'actions' => ['view' => 'مشاهده', 'update' => 'ویرایش'],
],
];
/** شکلِ خالی — برای merge با مقدارِ ذخیره‌شده. */
public static function blank(): array { /* همهٔ actionها false */ }
/** مقدارِ ذخیره‌شده + کلیدهای نبوده از پیش‌فرضِ نقش. */
public static function merge(array $stored, array $roleDefaults): array { /* … */ }
}
```
**دو منبع تازه که باید اضافه شوند** (چون صفحه‌شان امروز مجوزِ قرضی دارد):
`resources` برای پنج صفحهٔ منابع، و `treatment` برای `treatment-cases`.
**الگو: Registry/Catalog.** دلیل: چهار فهرستِ موازی که دستی هم‌گام می‌شوند، دیر یا زود
واگرا می‌شوند — و همین حالا شده‌اند. یک ثابت که هر چهار مصرف‌کننده از آن می‌خوانند،
واگرایی را از نظر ساختاری غیرممکن می‌کند.
**نحوه تست:** تست واحد در `tests/Shared/``blank()` باید برای هر منبعِ رجیستری کلید
بدهد؛ `merge()` با یک `stored` که یک منبع کم دارد باید آن را از پیش‌فرضِ نقش پر کند و
مقادیرِ موجود را **دست نزند**.
---
### ۲. هر دو Entity از رجیستری بخوانند
`DEFAULT_PERMISSIONS` هر دو کلاس می‌ماند ولی معنی‌اش عوض می‌شود: فقط **پیش‌فرضِ نقش**،
نه تعریفِ ساختار. اعتبارسنجیِ `apply()` باید به `PermissionCatalog::RESOURCES` نگاه کند
نه به `self::DEFAULT_PERMISSIONS`.
```php
// ClinicDoctorPermission::apply() — امروز:
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; }
// باید بشود:
if (!is_array($actions) || !isset(PermissionCatalog::RESOURCES[$resource])) { continue; }
```
با این تغییر، `services.create` برای پزشک هم قابل ذخیره می‌شود — چون رجیستری واحد
است. همین «شبیه شدنِ منشی و پزشک» است.
**نحوه تست:** تست موجودِ مجوز پزشک را اجرا کن، بعد یک تست تازه: ذخیرهٔ
`services.create = true` برای پزشک، خواندن دوباره، و بررسی اینکه مانده — امروز دور
ریخته می‌شود.
---
### ۳. اندپوینت کاتالوگ
`GET /api/v1/permission-catalog`
**Permission:** `IS_AUTHENTICATED_FULLY`. پاسخ ثابت است و به کاربر بستگی ندارد، پس
جای مناسبی برای cache سمت کلاینت است (`staleTime` بلند).
```json
{ "success": true, "data": { "resources": [
{ "key": "appointments", "label": "نوبت‌ها",
"actions": [ { "key": "view", "label": "مشاهده" }, ] }
] } }
```
آرایه است نه object، تا ترتیبِ نمایش تضمین شود؛ ترتیبِ کلیدهای JSON قرارداد نیست.
**نحوه تست:**
```bash
TOKEN=... # 09390039833 / QaTest@1234
curl -sk https://clinic-pro.ddev.site/api/v1/permission-catalog -H "Authorization: Bearer $TOKEN"
```
باید همهٔ منابع بیایند. بدون توکن → ۴۰۱.
---
### ۴. هر دو UI از کاتالوگ رندر شوند
`RESOURCE_LABELS` و `EMPTY_PERMISSIONS` و `PERMISSION_SECTIONS` حذف می‌شوند و جایشان
یک hook می‌آید:
```ts
// assets/admin/hooks/usePermissionCatalog.ts
export function usePermissionCatalog() {
return useQuery({
queryKey: ['permission-catalog'],
queryFn: () => api.get<ApiResponse<{ resources: CatalogResource[] }>>('/api/v1/permission-catalog'),
staleTime: Infinity,
});
}
```
`clinicOnly` که امروز در `PERMISSION_SECTIONS` است باید به رجیستری منتقل شود، وگرنه
همان منطق در فرانت هاردکد می‌ماند.
سه حالت داده اجباری است: تا وقتی کاتالوگ نیامده، فرم اسکلتون نشان دهد نه فهرست خالی —
«هیچ مجوزی وجود ندارد» با «در حال خواندن» یکی نیست.
**نحوه تست:** vitest برای هر دو کامپوننت با mock کردن کاتالوگ؛ سناریو: کاتالوگ با سه
منبع → سه بخش رندر شود. سپس یک منبع به mock اضافه کن و بررسی کن بدون تغییر کد ظاهر شود
— این همان «داینامیک بودن» است و باید تست داشته باشد.
---
### ۵. گیتِ درستِ صفحه‌ها
`App.tsx` — پنج صفحهٔ منابع از `appointment_settings` به `resources` منتقل شوند و
`treatment-cases` به `treatment`.
سمت backend هم باید همان‌جا بسته شود، وگرنه گیتِ فرانت فقط دکوراسیون است:
`TreatmentCaseController` و کنترلرهای `src/Resource/` باید
`secretaryAccess->denyUnlessGranted($user, 'treatment', 'view')` بزنند — الگوی موجود در
`StaffController::list()` را ببین.
> **این را قبل از کد بررسی کن:** آیا صفحهٔ دیگری هم مجوزِ قرضی دارد؟ خروجی این دستور را
> با فهرست صفحه‌ها مقایسه کن:
> ```bash
> grep -oE 'path="[a-z-]+"|permission=\{\[[^]]*\]' assets/admin/App.tsx
> ```
**نحوه تست:** منشی‌ای با `treatment.view = false` بساز، توکنش را بگیر و
`GET /api/v1/treatment-cases` بزن → باید ۴۰۳ بدهد. بعد `true` کن و ۲۰۰ بگیر.
---
### ۶. بررسی و تست همهٔ صفحه‌ها
کاربر صریح خواسته «همه صفحات بررسی و تست شود». این یعنی یک عبورِ سیستماتیک، نه نگاه
اجمالی:
۱. با کاربر منشی و **همهٔ مجوزها خاموش**، هر route را باز کن. هیچ صفحه‌ای نباید داده
نشان دهد؛ همه باید به داشبورد برگردند.
۲. یکی‌یکی `view` هر منبع را روشن کن و بررسی کن **فقط** صفحه‌های همان منبع باز شوند.
۳. همان دو مرحله برای پزشکِ دعوت‌شده.
اسکریپت کمکی برای مرحلهٔ ۱ (درایورِ اسکرین‌شات آدرسِ نهایی را گزارش می‌کند):
```bash
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png --wait 5000
# ⚠ WRONG PAGE یعنی گیت کار کرده
```
نتیجه را به‌صورت جدول گزارش کن: route، منبعِ گیت، رفتار مشاهده‌شده. **صفحه‌ای که تست
نشده را «تست شد» ننویس.**
---
## نکات مهم
- **رجیستری فقط ساختار می‌دهد، نه سیاست.** پیش‌فرضِ هر نقش در همان Entity می‌ماند.
آوردنشان به رجیستری یعنی یک فایل که هم «چه چیزی هست» و هم «چه کسی چه دارد» را
می‌داند — دو مسئولیت.
- **هیچ migration دادهٔ انبوهی ننویس.** `merge()` در زمان خواندن کار را می‌کند و
ستون `json` هر دو جدول نیازی به تغییر schema ندارد. migration فقط اگر ستونی اضافه
شود، که اینجا نمی‌شود.
- مالک کلینیک و ادمین همیشه مجازند — این قاعده در `ClinicDoctorPermissionChecker::can()`
است و دست نمی‌خورد. مالک هرگز نباید بتواند خودش را قفل کند.
- `usePermissions` نبودِ `permissions` را «محدودیتی نیست» می‌خواند (محیط شخصی). این
رفتار نباید عوض شود؛ رجیستری فقط جایی اثر دارد که مجوز واقعاً ذخیره شده باشد.
- بعد از تغییر اندپوینت‌ها، `docs/api/secretary.md` و `docs/api/clinic.md` در همان
جلسه به‌روز شوند، با JSON واقعی از اجرای واقعی.
- تستِ «داینامیک بودن» را جدی بگیر: تستی که فقط منابعِ امروز را چک کند، فردا که منبع
تازه اضافه شود چیزی به تو نمی‌گوید. تست باید **افزودنِ یک منبع به mock** را بررسی کند.
+442
View File
@@ -0,0 +1,442 @@
# طول درمان و پرونده درمان چندجلسه‌ای (فاز اول: لیزر)
## پروژه
`clinicpro` (backend + پنل ادمین). هیچ تغییری در `nobat724_front` و `clinic-pro-tauri` لازم نیست.
## زمینه
این سند خروجی یک جلسه grilling است. ۲۳ تصمیم قفل شد، شش ADR و یک glossary نوشته شد.
پیش از شروع این‌ها را بخوان:
- `clinicpro/CONTEXT.md` — واژگان رسمی این دامنه
- `clinicpro/docs/adr/0001-treatment-sessions-are-not-appointments.md`
- `clinicpro/docs/adr/0002-treatment-areas-are-snapshotted.md`
- `clinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md`
- `clinicpro/docs/adr/0004-session-parameters-are-json-keyed-by-resource-type.md`
- `clinicpro/docs/adr/0005-treatment-workflows-are-tagged-services.md`
- `clinicpro/docs/adr/0006-clinical-record-is-separate-from-the-visit-record.md`
- `clinicpro/docs/architecture/resource-first-model.md`
از واژگان `CONTEXT.md` استفاده کن. مترادف نساز.
## هدف
کلینیک بتواند سرویسی تعریف کند که درمانش چند جلسه طول می‌کشد، و سیستم برای هر بیمار
پرونده درمان بسازد، جلسات را بشمارد، سررسید جلسه بعد را حساب کند، و اپراتور بتواند
برای هر ناحیه بدن، دستگاه و پارامترهایش را ثبت کند.
فاز اول فقط لیزر. ولی هیچ‌جای کد نباید کلمه «لیزر» را بداند، جز پیاده‌سازی workflow.
## آنچه از قبل هست و باید استفاده شود
| چیز | کجا | نکته |
|---|---|---|
| دسته‌بندی گرافی خدمات | `CatalogCategory` + `CatalogCategoryInclude` | نواحی بدن همین‌جا تعریف می‌شوند |
| بستار گذرا و تشخیص تعارض | `CategoryClosureResolver` | `descendants()` و `overlaps()` |
| سرویس | `ServiceItem` با `catalogCategory` | قیمت و مدت اینجاست |
| نوع منبع داینامیک | `ResourceType` | کدهای سیستمی `doctor` / `staff` / `room` |
| منبع با پزشک ناظر | `ClinicResource.supervisor` | تعریف شده ولی در مسیر رزرو خوانده نمی‌شود |
| اشغال منبع در سطح دیتابیس | `OccupancyBucket` | `uniq_bucket_resource_seat(resource_id, bucket_at, seat)` |
| پرسنل و نقش | `ClinicStaff` + `ROLE_STAFF` | `ClinicStaff.user` اختیاری است |
| داشبورد پرسنل | `GET /api/v1/dashboard/staff` | فیلتر روی `a.staff` |
| صفحه پرسنل | `assets/admin/pages/StaffMyServicesPage.tsx` | فقط سرویس‌های تخصیص‌یافته |
| مراجعه مالی | `PatientSession` | در `AppointmentConfirmationService::onConfirmed` ساخته می‌شود |
## آنچه نیست
هیچ موجودیتی برای پرونده درمان، جلسه درمان، ثبت ناحیه، پروتکل، یا حوزه فعالیت.
`ServiceItem.sessionCount` هست ولی هیچ منطقی از آن استفاده نمی‌کند — عملاً مرده.
---
## تصمیم‌های قفل‌شده
این‌ها در جلسه grilling تصمیم‌گیری شده‌اند. اجرایشان کن، دوباره طراحی نکن.
اگر جایی از کد با یکی از این‌ها تناقض داشت، **متوقف شو و بپرس**؛ خودت تصمیم را عوض نکن.
1. **حوزه فعالیت از `Specialty` جداست.** `Specialty` قرارداد سایت عمومی است و دست نمی‌خورد.
2. **هر کلینیک یک حوزه فعالیت دارد.** نال یعنی تنظیم‌نشده و رفتار امروز.
3. **جلسه درمان موجودیت مستقل است**، نه `Appointment`.
4. **زمان‌بندی با لیست صریح گام‌ها.** فاصله ثابت نداریم.
5. **دوره پایان مشخص دارد.** دوره بی‌پایان وجود ندارد.
6. **همه جلسات از ابتدا ساخته می‌شوند**، ولی فقط جلسه بعدی نوبت می‌گیرد.
7. **ناحیه درمان همان `CatalogCategory` است.** هیچ entity جدیدی برای ناحیه ساخته نشود.
8. **نواحی از دسته سرویس مشتق می‌شوند**، هنگام رزرو انتخاب نمی‌شوند.
9. **فهرست نواحی هنگام ساخت پرونده قفل می‌شود** (snapshot).
10. **پزشک ناظرِ منبع روی نوبت می‌نشیند.** پرسنل انجام می‌دهد.
11. **نوبتی که منبع دارد `active_slot_key` ندارد.** حفاظت فقط با `OccupancyBucket`.
12. **`TreatmentProtocol` موجودیت جداست**، یک‌به‌یک با `ServiceItem`.
13. **پارامترهای دستگاه در JSON**، تعریفشان روی `ResourceType`.
14. **Workflow با tagged service.** موتور داده‌محور نداریم.
15. **حوزه فعالیت را فقط ادمین پلتفرم می‌سازد.**
16. **جلسه وضعیت مستقل دارد** و نوبت را هم‌گام می‌کند. بستن جلسه با ناحیه ناتمام مجاز است.
17. **`TreatmentSession` بالینی است، `PatientSession` مالی.** هیچ فیلد پولی روی جلسه درمان.
18. **قیمت هر جلسه با قیمت روز.** پکیج قیمت نداریم. قیمت هیچ‌وقت قفل نمی‌شود.
19. **جلسه بعد پیشنهاد می‌شود، منشی تأیید می‌کند.** رزرو خودکار بدون انسان نداریم.
20. **حوزه فعالیت فقط workflow را انتخاب می‌کند.** داشبورد اختصاصی به‌ازای تخصص نداریم.
21. **سررسید هر جلسه نسبی به تاریخ واقعی جلسه قبل است.**
22. **no-show جلسه را نمی‌سوزاند.** تعداد جلسات ثابت می‌ماند.
23. **زمان واقعی جدا ثبت می‌شود.** `slotStart` و `slotEnd` هرگز بازنویسی نمی‌شوند.
---
## وظایف
هر وظیفه را جداگانه پیاده کن، تست بنویس، و بعد سراغ بعدی برو.
### ۱. حوزه فعالیت کلینیک
موجودیت جدید در `src/PracticeDomain/Entity/PracticeDomain.php`:
```php
// جدول سراسری (global) — نه per-tenant. در GlobalTables ثبت شود.
id, uuid, code (unique), name, sort_order, active, created_at, updated_at
```
`code` پایدار است و workflow به آن bind می‌شود. بعد از ساخت قابل ویرایش نیست.
روی `Clinic` یک `ManyToOne` نال‌پذیر اضافه کن:
```php
#[ORM\ManyToOne(targetEntity: PracticeDomain::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?PracticeDomain $practiceDomain = null;
```
migration نباید مقدار پیش‌فرض برای کلینیک‌های موجود بگذارد. نال بماند.
اندپوینت‌ها:
- `GET /api/v1/practice-domains` — فهرست فعال‌ها. برای همه نقش‌های پنلی.
- `POST /api/v1/admin/practice-domains` — فقط `ROLE_ADMIN`.
- `PATCH /api/v1/admin/practice-domains/{uuid}` — فقط `name` و `active` و `sort_order`.
- `PATCH /api/v1/clinic/{uuid}/practice-domain` — مدیر کلینیک انتخاب می‌کند.
در پاسخ هر حوزه یک فیلد `has_workflow` بگذار که از `TreatmentWorkflowRegistry` می‌آید.
پنل ادمین پلتفرم باید بتواند نشان دهد کدام حوزه هنوز workflow ندارد.
seed اولیه با migration: `beauty` = «کلینیک زیبایی».
### ۲. پروتکل درمان
سه موجودیت در `src/Treatment/Entity/`:
```php
// TreatmentProtocol — وجود این ردیف یعنی سوییچ «طول درمان» روشن است
id, uuid, service_item_id (unique, ON DELETE CASCADE),
supervisor_doctor_id (nullable), active, created_at, updated_at
// TreatmentProtocolStep — گام‌های دوره
id, protocol_id, step_number, offset_days, created_at
// UNIQUE(protocol_id, step_number)
// offset_days یعنی «فاصله از جلسهٔ قبل»، نه از شروع دوره. گام ۱ همیشه offset_days = 0.
// TreatmentProtocolStaff — پرسنل مجاز به انجام
id, protocol_id, staff_id
// UNIQUE(protocol_id, staff_id)
```
قواعد اعتبارسنجی:
- حداقل دو گام. پروتکل یک‌جلسه‌ای معنی ندارد؛ آن یعنی سوییچ خاموش.
- `step_number` پیوسته از ۱.
- گام اول `offset_days = 0`. بقیه بزرگ‌تر از صفر.
- حداقل یک پرسنل مجاز.
- `supervisor_doctor_id` باید پزشکِ همان محیط باشد.
`ServiceItem::$sessionCount` را `@deprecated` علامت بزن، از `toArray()` بیرون **نبر**
(پنل و تایپ TS به آن وابسته‌اند) ولی هیچ منطق جدیدی از آن نخوان. تعداد جلسات
همیشه `count(protocol.steps)` است.
اندپوینت‌ها زیر `/api/v1/clinic-services/{serviceUuid}/treatment-protocol`:
`GET`، `PUT` (کل پروتکل با گام‌ها و پرسنل یکجا)، `DELETE` (خاموش کردن سوییچ).
### ۳. پرونده و جلسه درمان
```php
// TreatmentCase
id, uuid, entity_type, entity_id, // tenant
patient_record_id, service_item_id, protocol_id,
supervisor_doctor_id (nullable),
status, // active | completed | abandoned
total_sessions, // snapshot از تعداد گام‌ها
opened_at, closed_at (nullable),
created_at, updated_at
// TreatmentCaseArea — snapshot نواحی (تصمیم ۹)
id, case_id, catalog_category_id, name_snapshot, sort_order
// UNIQUE(case_id, catalog_category_id)
// TreatmentSession
id, uuid, case_id, session_number,
appointment_id (nullable, ON DELETE SET NULL),
performed_by_staff_id (nullable), // تصمیم ۲۳
status, // planned | booked | in_progress | done | cancelled | no_show
due_at (nullable), // تخمینی، بعد از هر جلسه بازمحاسبه می‌شود
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(case_id, session_number)
// SessionAreaRecord
id, uuid, session_id, case_area_id,
resource_id (nullable), // دستگاه — در سطح ناحیه
status, // pending | in_progress | completed | skipped
parameters JSON (nullable), // { "energy": 18, "pulse": 3, "shots": 212 }
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(session_id, case_area_id)
```
**هیچ ستون پولی روی این جدول‌ها نگذار.** ADR-0006.
`name_snapshot` روی `TreatmentCaseArea` عمدی است: اگر مدیر بعداً اسم دسته را عوض کند،
سابقه درمان نباید تغییر کند.
نواحی هنگام ساخت پرونده اینطور حساب می‌شوند:
```
leaves = برگ‌های CategoryClosureResolver::descendants(service.catalogCategory)
اگر descendants خالی بود → خودِ service.catalogCategory تنها ناحیه است
```
«برگ» یعنی دسته‌ای که خودش `descendants` ندارد. دسته‌های میانی فقط گروه‌بندی‌اند و
ناحیه درمان نیستند.
### ۴. اتصال پزشک ناظر به مسیر رزرو
در `AppointmentController` بلوکی که پزشک را از منبع استنتاج می‌کند (حدود خط ۴۷۱)
یک fallback اضافه کن:
```php
if ($doctorUuid === '' && $resource->subject() instanceof Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
// جدید:
if ($doctorUuid === '' && $resource->getSupervisor() !== null) {
$doctorUuid = $resource->getSupervisor()->getUuid();
}
```
اگر منبعی نه پزشک است نه ناظر دارد، خطای واضح بده:
«این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید».
پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراه‌کننده است.
### ۵. رزرو منبع مستقل از پزشک
**این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.**
آنچه با داده تأیید شد:
- دو مسیر رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد.
مسیر پنل روی `appointments.resource_id` می‌نشیند، مسیر hold روی `resource_occupancy`.
- `bookAtomically` روی **پزشک** قفل می‌گیرد و `isSlotTaken` تداخل بازه‌ای را فقط روی پزشک
می‌سنجد. منبع در آن کوئری نیست.
- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶،
کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است.
- برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمی‌گرداند.
پنج تغییر:
۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همان‌طور که `HoldService` می‌سازد.
منطق مشترک در یک سرویس باشد، در دو جا کپی نشود.
۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند.
۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند:
```php
$this->activeSlotKey = (!$this->isReserve
&& $this->resource === null
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true))
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
```
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند.
۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند.
تضمین یکتایی از `uniq_bucket_resource_seat` می‌آید که ظرفیت و `seat` را می‌فهمد.
۵. شعبه نوبتِ منبع‌دار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک.
**قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدام‌ها منبع ست نمی‌کنند.
آن‌ها کلید پزشک و قفل پزشک را نگه می‌دارند. این فهرست باید در گزارش بیاید.
migration برای `active_slot_key` لازم نیست. برای ردیف‌های اشغالِ گذشتهٔ مسیر پنل یک
migration داده‌ای لازم است تا نوبت‌های فعالِ منبع‌دار موجود ردیف اشغال بگیرند.
### ۶. تعریف فیلد روی نوع منبع
ستون JSON روی `ResourceType`:
```php
#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
private ?array $fieldSchema = null;
```
قالب هر فیلد:
```json
{ "key": "energy", "label": "انرژی", "type": "select",
"options": [7, 8, 9, 10, 12, 14, 16, 18], "required": true, "sort_order": 1 }
```
`type` مجاز: `select` و `number` و `text`. همین سه تا، نه بیشتر.
اعتبارسنجی مقادیر `SessionAreaRecord.parameters` از همین schema می‌آید. یک سرویس
`FieldSchemaValidator` بنویس که هم در ذخیره اندپوینت استفاده شود هم در تست.
کلیدهای ناشناخته که در schema نیستند رد شوند، نه اینکه بی‌صدا ذخیره شوند.
seed اولیه: یک `ResourceType` با کد `laser_device` و نام «دستگاه لیزر» و همان سه فیلد
بالا به‌علاوه `shots` از نوع `number`. `is_system = false` تا مدیر بتواند ویرایشش کند.
### ۷. Workflow قابل توسعه
```php
// src/Treatment/Workflow/TreatmentWorkflow.php
#[AutoconfigureTag('app.treatment_workflow')]
interface TreatmentWorkflow
{
public function supports(?string $practiceDomainCode): bool;
/** بعد از تأیید اولین نوبتِ یک سرویسِ پروتکل‌دار */
public function openCase(Appointment $appointment, TreatmentProtocol $protocol): TreatmentCase;
/** بعد از بسته شدن یک جلسه — سررسید جلسه بعد را حساب می‌کند */
public function onSessionFinished(TreatmentSession $session): void;
}
```
`TreatmentWorkflowRegistry` با `#[TaggedIterator('app.treatment_workflow')]` ساخته شود و
اولین workflow ای که `supports()` بدهد را برگرداند. اگر هیچ‌کدام، `DefaultTreatmentWorkflow`
که رفتار عمومی دارد.
**هسته نباید بداند لیزر چیست.** `AppointmentConfirmationService` فقط این را می‌کند:
```
اگر سرویسِ نوبت پروتکل فعال دارد و بیمار پروندهٔ باز برای همان سرویس ندارد
→ registry->for(clinic.practiceDomain?.code)->openCase(...)
```
`LaserTreatmentWorkflow` در فاز اول تقریباً همان `DefaultTreatmentWorkflow` است.
جدا نگهش دار حتی اگر خالی باشد — نقطه اتصال آینده است.
### ۸. سررسید و جلسه بعد
قاعده محاسبه (تصمیم ۲۱):
```
due_at(session n) = finished_at(session n-1) + protocol.step[n].offset_days
جلسه ۱ سررسید ندارد؛ تاریخش همان نوبت اول است.
```
بعد از `finished_at` شدن هر جلسه، فقط `due_at` **جلسه بعدی** بازمحاسبه شود، نه کل دوره.
جلسات دورتر تخمین قبلی‌شان را نگه می‌دارند تا نوبتشان برسد.
no-show (تصمیم ۲۲): جلسه به `no_show` می‌رود، `session_number` عوض نمی‌شود،
`total_sessions` عوض نمی‌شود. یک جلسه جایگزین با همان شماره **ساخته نمی‌شود**؛ همان جلسه
دوباره به `planned` برمی‌گردد و `due_at` از تاریخ جلسه قبلِ **انجام‌شده** حساب می‌شود.
رزرو جلسه بعد (تصمیم ۱۹) **خودکار نیست**. اندپوینت پیشنهاد بده:
```
GET /api/v1/treatment-sessions/{uuid}/slot-suggestions
→ اسلات‌های آزاد منبع، از due_at به بعد، با استفاده از ResourceFreeTimeCalculator
```
منشی یکی را انتخاب می‌کند و مسیر عادی ساخت نوبت اجرا می‌شود، سپس نوبت به جلسه وصل می‌شود.
### ۹. اندپوینت‌های اجرای جلسه
همه زیر `ROLE_STAFF` یا بالاتر. علاوه بر نقش، بررسی کن پرسنل واقعاً به این جلسه دسترسی
دارد — الگویش در `DashboardController::staff` هست (`findActiveByUserAndEntity`).
```
GET /api/v1/dashboard/staff/treatment-sessions جلسات امروز پرسنل
GET /api/v1/treatment-sessions/{uuid} جزئیات + نواحی + فیلدهای دستگاه
POST /api/v1/treatment-sessions/{uuid}/start → in_progress، started_at
POST /api/v1/treatment-sessions/{uuid}/finish → done، finished_at، note
POST /api/v1/session-areas/{uuid}/start → in_progress، started_at
POST /api/v1/session-areas/{uuid}/complete → completed، parameters، finished_at
POST /api/v1/session-areas/{uuid}/skip → skipped
GET /api/v1/treatment-cases فهرست پرونده‌ها با فیلتر
GET /api/v1/treatment-cases/{uuid} پرونده + همه جلسات
```
هم‌گام‌سازی وضعیت نوبت (تصمیم ۱۶):
```
session start → Appointment::STATUS_SALON
session finish → Appointment::STATUS_COMPLETED
```
از `ALLOWED_TRANSITIONS` عبور کن، مستقیم `setStatus` نزن. اگر گذار مجاز نبود، جلسه را
ببند ولی نوبت را دست نزن و لاگ بگذار — خطای ۵۰۰ نده.
بستن جلسه با ناحیه ناتمام **مجاز است** (تصمیم ۱۶). فقط در پاسخ تعداد ناتمام‌ها را برگردان
تا پنل هشدار نشان دهد.
`started_at` و `finished_at` هرگز روی `Appointment` نوشته نشوند (تصمیم ۲۳).
### ۱۰. پنل ادمین
قواعد اجباری این پروژه:
- هر `select` باید `components/ui/SearchableSelect` باشد. `<select>` بومی ممنوع.
- هر بولین باید `components/ui/Switch` باشد. checkbox بومی ممنوع.
- صفحه جدید با تم و Layout و کامپوننت‌های موجود ساخته شود. طراحی جدید نکن.
- تاریخ‌ها شمسی. متن‌ها فارسی. RTL.
صفحه‌ها و تغییرها:
1. **فرم سرویس** — سوییچ «طول درمان». روشن که شد: جدول گام‌ها (شماره و فاصله از جلسه قبل)،
`SearchableSelect` چندتایی پرسنل مجاز، `SearchableSelect` پزشک ناظر.
2. **تنظیمات کلینیک**`SearchableSelect` حوزه فعالیت.
3. **صفحه نوع منابع** — ویرایشگر `fieldSchema`. افزودن و حذف فیلد، انتخاب نوع، گزینه‌ها.
4. **پنل ادمین پلتفرم** — CRUD حوزه‌های فعالیت با نشان «workflow دارد / ندارد».
5. **فهرست پرونده‌های درمان** — نام بیمار، سرویس، جلسه چندم از چند، وضعیت، سررسید بعدی، اپراتور.
6. **صفحه اجرای جلسه** — کارت هر ناحیه با دکمه شروع، تایمر زنده، فرم داینامیک از `fieldSchema`،
دکمه اتمام ناحیه، دکمه لغو ناحیه. پایین صفحه یادداشت کلی و دکمه اتمام جلسه با هشدار
نواحی ناتمام. اسکرین‌شات‌های مرجع در تیکت این کار هست.
7. **صف «جلسات بدون نوبت»** — جلساتی که `status = planned` و `due_at` گذشته یا نزدیک است.
از هر ردیف مستقیم به انتخاب اسلات پیشنهادی.
تایمر فقط نمایشی است. زمان معتبر همان `started_at` و `finished_at` سرور است.
---
## ترتیب اجرا
```
۱ → ۲ → ۳ → ۶ → ۷ → ۴ → ۵ → ۸ → ۹ → ۱۰
```
وظیفه ۵ (کلید اسلات) روی پرترافیک‌ترین جدول سیستم است. بعد از وظیفه ۴ انجامش بده و
قبل و بعدش تست رگرسیون رزرو را کامل اجرا کن.
## تست
- unit برای `CategoryClosureResolver` در حالت برگ‌یابی نواحی
- unit برای محاسبه `due_at` با گام‌های نامساوی (سناریوی بوتاکس: ۰، ۱۵، ۳۰، ۳۰)
- unit برای `FieldSchemaValidator` با کلید ناشناخته و مقدار خارج از `options`
- functional: پزشک ناظر مشترک بین دو دستگاه، دو رزرو هم‌ساعت، هر دو باید موفق شوند
- functional: اتاق با `capacity = 3`، سه رزرو هم‌ساعت، هر سه باید موفق شوند
- functional: چرخه کامل یک دوره سه‌جلسه‌ای شامل یک no-show
- رگرسیون: رزرو بدون منبع همچنان با کلید پزشک از دوبار رزرو جلوگیری کند
## خارج از دامنه فاز اول
- seeder هوشمند با AI برای پیشنهاد دسته‌بندی و منابع
- گزارش‌های تحلیلی روی `parameters` (کوئری JSON بدون ایندکس)
- حوزه فعالیت دوم غیر از زیبایی
- داشبورد اختصاصی به‌ازای هر تخصص
- قیمت پکیج و قفل قیمت
## مستندسازی
طبق قانون ثابت این پروژه، `docs/api/*` در همان session به‌روز شود.
اگر تصمیمی در حین اجرا عوض شد، ADR مربوطه به‌روز شود یا ADR جدید نوشته شود.
@@ -0,0 +1,327 @@
# الگوی شماره پرونده — تنظیمات per-tenant + تولید خودکار شماره پرونده
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React). cross-repo نیست: `record_number` در `nobat724_front` مصرف نمی‌شود (بررسی شد).
## زمینه
`PatientRecord` از قبل ستون `record_number` دارد، ولی امروز:
- **متن آزاد و دستی** است: کاربر در فرم ساخت پرونده تایپش می‌کند و zod فقط `min(1)` را چک می‌کند.
- **هیچ قید یکتایی ندارد**: تنها unique روی `patient_records` جفت `(entity_type, entity_id, user_id)` است، نه شماره پرونده. دو پرونده می‌توانند شمارهٔ یکسان بگیرند.
- **پرونده‌های خودکار اصلاً شماره نمی‌گیرند**: وقتی نوبت قطعی می‌شود، `PatientService` پرونده را `new PatientRecord(...)` می‌سازد و `record_number` را `null` می‌گذارد. یعنی بخش بزرگی از پرونده‌ها بی‌شماره‌اند.
صاحب کلینیک/پزشک می‌خواهد یک **روند** داشته باشد: الگوی شماره را یک‌بار تعریف کند و از آن به بعد هر پرونده‌ای که ثبت می‌شود خودش شماره بگیرد.
## مشکل / هدف
۱. یک تنظیمِ per-tenant «الگوی شماره پرونده» با توکن‌های مشخص و شمارندهٔ خودکار.
۲. تولید شمارهٔ پرونده **سمت سرور** در **هر دو** مسیر ساخت پرونده (دستی از پنل، خودکار از نوبت).
۳. یکتایی شماره در هر محیط، حتی زیر درخواست‌های هم‌زمان.
### تصمیم‌های گرفته‌شده (توسط کاربر — تغییرشان ندهید)
| تصمیم | مقدار |
|---|---|
| ریست شمارنده | قابل انتخاب: `none` / `yearly` / `monthly` (بر مبنای تقویم **شمسی**) |
| ورود دستی شماره | فقط **صاحب محیط** (پزشکِ مالک مطب / مالک کلینیک) و `ROLE_ADMIN`. منشی و پرسنل و پزشکِ مهمان → فقط شمارهٔ تولیدشده |
| پرونده‌های قدیمیِ بی‌شماره | **lazy backfill**: اولین بار که پرونده باز می‌شود (`GET /api/v1/patient/{uuid}`) شماره می‌گیرد |
## معیار پذیرش
-**موفق (تنظیمات):** `PUT /api/v1/patient-record-number-settings` با بدنهٔ `{enabled: true, pattern: "MD-{YY}-{SEQ:4}", reset_policy: "yearly"}` توسط مالک کلینیک → `200` و `data.preview === "MD-05-0001"` (برای سال ۱۴۰۵ و شمارندهٔ صفر). `GET` همان مسیر مقدار ذخیره‌شده + `next_preview` را برمی‌گرداند.
-**موفق (ساخت دستی):** با الگوی فعال، `POST /api/v1/patient` بدون فیلد `record_number``201` و `data.record_number === "MD-05-0001"`؛ پروندهٔ بعدی `MD-05-0002`.
-**موفق (ساخت خودکار):** قطعی‌کردن یک نوبت که پرونده ندارد → پروندهٔ ساخته‌شده توسط `PatientService` هم `record_number` غیر `null` دارد و در همان دنبالهٔ شماره‌ها است.
-**موفق (backfill تنبل):** پرونده‌ای با `record_number = null` که پیش از فعال‌شدن الگو ساخته شده، بعد از یک `GET /api/v1/patient/{uuid}` شماره می‌گیرد و در `GET` دوم **همان** شماره برمی‌گردد (نه شمارهٔ تازه).
-**خطا (الگوی نامعتبر):** `PUT` با `pattern: "MD-{FOO}"``422` با `field: pattern` و پیام فارسی؛ `pattern` بدون هیچ `{SEQ}``422` (وگرنه همهٔ پرونده‌ها یک شماره می‌گرفتند).
-**خطا (دسترسی):** منشیِ همان کلینیک روی `PUT` تنظیمات → `403`. منشی‌ای که در `POST /api/v1/patient` فیلد `record_number` می‌فرستد → `403` (یا فیلد بی‌صدا نادیده گرفته نشود؛ خطا صریح باشد).
- ⚠️ **مرزی (هم‌زمانی):** دو `POST /api/v1/patient` هم‌زمان در یک محیط → دو شمارهٔ **متفاوت**؛ هیچ‌کدام ۵۰۰ نمی‌دهد.
- ⚠️ **مرزی (ریست سالانه):** با `reset_policy: yearly`، اولین پروندهٔ سال شمسی بعدی دوباره از `{SEQ}=1` شروع می‌شود و با پروندهٔ سال قبل تداخل ندارد (چون `{YY}` در الگوست).
- ⚠️ **مرزی (الگوی خاموش):** با `enabled: false` رفتار امروز حفظ می‌شود — شماره تولید نمی‌شود و ورود دستی برای همه باز است.
- ⚠️ **مرزی (سرریز شمارنده):** `{SEQ:3}` وقتی شمارنده به ۱۰۰۰ می‌رسد → شماره بدون پدینگ اضافه ادامه پیدا کند (`1000`)، نه اینکه بریده شود یا خطا بدهد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/src/Patient/Entity/PatientRecord.php` | ستون `record_number` (خط ۴۸) — هدفِ مقداردهی |
| `clinicpro/src/Patient/Controller/PatientController.php` | `POST /api/v1/patient` (خط ~۷۸۴)، `GET /api/v1/patient/{uuid}` (خط ۷۹۹)، `PATCH` (خط ~۹۳۵) |
| `clinicpro/src/Patient/Service/PatientService.php` | ساخت خودکار پرونده از نوبت (خط ~۲۱۱) |
| `clinicpro/src/Insurance/Entity/TenantServiceCategorySetting.php` | **الگوی مرجع** برای یک تنظیم per-tenant |
| `clinicpro/src/Representation/Service/JalaliDateService.php` | تبدیل شمسی (`gregorianToJalali`) برای توکن‌های `{YY}`/`{MM}` |
| `clinicpro/src/Shared/Context/EntityContextResolver.php` | `ownedEntity($user)` — تشخیص «صاحب محیط» برای گیتِ ورود دستی |
| `clinicpro/assets/admin/pages/PatientRecordFormPage.tsx` | فیلد و اعتبارسنجی `record_number` (خطوط ۲۲، ۱۱۹) |
| `clinicpro/assets/admin/components/layout/settingsMenu.ts` | افزودن آیتم تنظیمات |
| `clinicpro/docs/api/patient.md` | مستند endpointها |
## وضعیت فعلی
**`src/Patient/Entity/PatientRecord.php:44-49`** — ستون بدون قید یکتایی:
```php
// Clinic-scoped case-file number. Patient identity/demographics (gender,
// date_of_birth, referral_source, description, insurance, …) live on the
// patient's UserProfile and are set via PATCH /patient/{uuid}.
#[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)]
private ?string $recordNumber = null;
```
**`src/Patient/Controller/PatientController.php:784-795`** — ساخت دستی، شماره فقط اگر کاربر فرستاده باشد:
```php
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
$record->setRecordNumber($rn);
}
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
if ($tagError !== null) {
return $tagError;
}
$this->recordRepo->save($record);
return $this->success($record->toArray(), 201);
```
**`src/Patient/Service/PatientService.php:209-213`** — ساخت خودکار، بدون هیچ شماره‌ای:
```php
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
```
**`assets/admin/pages/PatientRecordFormPage.tsx:22,119-120`** — فیلد دستیِ الزامی:
```tsx
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
...
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
```
---
## وظایف
### ۱. Entity + migration تنظیمات الگو
`src/Patient/Entity/RecordNumberPattern.php` — یک ردیف به ازای هر محیط، دقیقاً با سبک `TenantServiceCategorySetting` (همان `entity_type/entity_id` + unique constraint، timestamp عدد صحیح):
```php
#[ORM\Entity(repositoryClass: RecordNumberPatternRepository::class)]
#[ORM\Table(name: 'record_number_patterns')]
#[ORM\UniqueConstraint(name: 'uniq_record_number_pattern', columns: ['entity_type', 'entity_id'])]
class RecordNumberPattern
{
public const RESET_NONE = 'none';
public const RESET_YEARLY = 'yearly';
public const RESET_MONTHLY = 'monthly';
// entity_type, entity_id, enabled(bool), pattern(string 60),
// reset_policy(string 10), counter(int), counter_period(string 7, nullable),
// updated_at(int)
}
```
`counter_period` مهر دورهٔ فعلیِ شمارنده است (`'1405'` برای yearly، `'1405-05'` برای monthly، `null` برای none). با ورود دورهٔ جدید، `counter` صفر و `counter_period` به‌روز می‌شود — بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updated_at` قابل جواب بود.
سپس:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` سبز باشد؛ جدول با `ddev exec mysql -uroot -proot db -e "DESCRIBE record_number_patterns;"` وجود داشته باشد.
---
### ۲. یکتایی شمارهٔ پرونده در سطح دیتابیس
قبل از افزودن ایندکس، تداخل‌های موجود را ببین:
```sql
SELECT entity_type, entity_id, record_number, COUNT(*) c
FROM patient_records WHERE record_number IS NOT NULL
GROUP BY 1,2,3 HAVING c > 1;
```
اگر ردیفی برگشت، **به کاربر گزارش بده و متوقف شو** — پاک‌کردن خودسرانهٔ شمارهٔ پروندهٔ واقعی مجاز نیست. اگر خالی بود، unique index روی `(entity_type, entity_id, record_number)` اضافه کن (MariaDB چند `NULL` را در unique می‌پذیرد، پس پرونده‌های بی‌شماره مانع نمی‌شوند).
این ایندکس **تور ایمنی** است، نه مکانیزم اصلی؛ مکانیزم اصلی قفلِ وظیفهٔ ۳ است.
**نحوه تست:** درج دستی دو ردیف با شمارهٔ یکسان در یک محیط → خطای دیتابیس.
---
### ۳. سرویس تولید شماره — `RecordNumberGenerator`
`src/Patient/Service/RecordNumberGenerator.php`، تنها جایی که شماره ساخته می‌شود. سه مسئولیت جدا:
**الف) رندر الگو (خالص، بدون I/O):**
توکن‌های مجاز — هر چیز دیگری `422`:
| توکن | معنی |
|---|---|
| `{YY}` | دو رقم آخر سال شمسی (`05`) |
| `{YYYY}` | سال شمسی کامل (`1405`) |
| `{MM}` | ماه شمسی دو رقمی (`05`) |
| `{SEQ}` / `{SEQ:n}` | شمارنده، با `n` رقم پدینگ صفر (پیش‌فرض ۱) |
سال/ماه شمسی از `JalaliDateService::gregorianToJalali()` می‌آید — **پیاده‌سازی دستی ننویس**؛ همان فایل توضیح می‌دهد نسخهٔ دست‌ساز قبلی غلط بود.
**ب) گرفتن شمارهٔ بعدی (تراکنشی):**
```php
public function next(string $entityType, int $entityId): ?string
{
// بدون الگو یا با enabled=false → null (رفتار امروز)
return $this->em->wrapInTransaction(function () use ($entityType, $entityId): ?string {
$pattern = $this->repo->lockForUpdate($entityType, $entityId); // SELECT ... FOR UPDATE
if ($pattern === null || !$pattern->isEnabled()) {
return null;
}
$period = $this->periodKey($pattern->getResetPolicy());
if ($pattern->getCounterPeriod() !== $period) {
$pattern->resetCounter($period);
}
$pattern->incrementCounter();
return $this->render($pattern->getPattern(), $pattern->getCounter());
});
}
```
قفلِ ردیف (`LockMode::PESSIMISTIC_WRITE`) شرطِ معیار پذیرشِ «دو درخواست هم‌زمان» است؛ خواندن-افزایش-نوشتن بدون قفل، دو شمارهٔ یکسان می‌دهد.
**ج) پیش‌نمایش (بدون افزایش شمارنده):** `preview(string $pattern, string $resetPolicy): string` برای صفحهٔ تنظیمات.
**نکتهٔ SOLID:** این سرویس با constructor injection به `RecordNumberPatternRepository` و `JalaliDateService` وابسته است؛ `new` ممنوع. Controller و `PatientService` هر دو فقط `next()` را صدا می‌زنند — منطق دو جا کپی نشود.
**نحوه تست:** unit test روی `render()` برای هر توکن + سرریز `{SEQ:3}` روی ۱۰۰۰؛ و یک functional test که دو بار `next()` را صدا بزند و دو شمارهٔ متوالی بگیرد.
---
### ۴. Endpointهای تنظیمات
در `src/Patient/Controller/` (کنترلر جدید `RecordNumberSettingsController extends BaseController`):
| Method | Route | دسترسی |
|---|---|---|
| `GET` | `/api/v1/patient-record-number-settings` | هر کاربرِ محیط با `patients.view` |
| `PUT` | `/api/v1/patient-record-number-settings` | فقط صاحب محیط یا `ROLE_ADMIN` |
پاسخ `GET`:
```json
{ "success": true, "data": {
"enabled": true, "pattern": "MD-{YY}-{SEQ:4}", "reset_policy": "yearly",
"counter": 12, "next_preview": "MD-05-0013", "can_edit": true
} }
```
`can_edit` را از `EntityContextResolver::ownedEntity($user)` بساز (بررسی کن که این متد واقعاً همان چیزی را می‌دهد که لازم است؛ اگر نه، از `ClinicDoctorAccessChecker`/مالکِ `Clinic::getUser()` استفاده کن). پنل با همین فلگ فرم را read-only می‌کند — ولی **گیت اصلی سمت سرور است**، نه UI.
اعتبارسنجی `PUT`: `pattern` غیرخالی، حداکثر ۶۰ نویسه، فقط توکن‌های مجاز، حتماً شامل `{SEQ...}`، و `reset_policy` یکی از سه مقدار. هر خطا `422` با `field` درست از `$this->validationError()`/`$this->error()`.
**نحوه تست:**
```bash
T=$(ddev exec php bin/console lexik:jwt:generate-token -c 'App\\Auth\\Entity\\User' -- 09390039833)
curl -sk -X PUT https://clinic-pro.ddev.site/api/v1/patient-record-number-settings \
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly"}'
# سپس همان با توکن منشی 09390039875 → باید 403 بدهد
```
---
### ۵. اتصال به هر دو مسیر ساخت پرونده
**`PatientController::create`** — منطق فعلیِ خط ۷۸۶ عوض می‌شود:
```php
$manual = trim((string) ($data['record_number'] ?? ''));
if ($manual !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست', 403, 'record_number');
}
$record->setRecordNumber($manual !== '' ? $manual : $this->recordNumbers->next($entityType, $entityId));
```
**`PatientService`** (خط ~۲۱۱) — همان یک خط:
```php
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$record->setRecordNumber($this->recordNumbers->next($entityType, $entityId));
$this->recordRepo->save($record);
```
بدون این دومی، خواستهٔ «هر پرونده‌ای که ثبت می‌شود» برآورده نمی‌شود — بیشترِ پرونده‌ها از همین مسیرِ نوبت ساخته می‌شوند.
**نحوه تست:** با الگوی فعال یک `POST /api/v1/patient` بدون `record_number` بزن و شماره را ببین؛ سپس یک نوبت را قطعی کن (`POST /api/v1/appointment/{uuid}/confirm`) و شمارهٔ پروندهٔ ساخته‌شده را از `GET /api/v1/patients?search=...` بخوان.
---
### ۶. Backfill تنبل هنگام باز شدن پرونده
در `PatientController::show` (خط ۷۹۹)، **قبل از** ساخت پاسخ:
```php
if ($record->getRecordNumber() === null) {
$number = $this->recordNumbers->next($entityType, $entityId);
if ($number !== null) {
$record->setRecordNumber($number);
$this->recordRepo->save($record);
}
}
```
سه نکته که باید رعایت شود:
- فقط وقتی الگو فعال است (`next()` خودش `null` برمی‌گرداند) — وگرنه یک `GET` ساده تبدیل به نوشتن بی‌دلیل می‌شود.
- idempotent: بار دوم چون شماره پر است، هیچ نوشتنی رخ نمی‌دهد. (معیار پذیرشِ «GET دوم همان شماره»)
- دو `GET` هم‌زمان روی یک پرونده: قفلِ وظیفهٔ ۳ دو شمارهٔ متفاوت می‌دهد ولی آخرین نوشتن برنده است و یک شماره هدر می‌رود. قابل قبول است؛ **در کد کامنت شود** که چرا (هدررفتِ یک شماره در برابر پیچیدگی قفل روی خودِ رکورد).
**نحوه تست:** یک `patient_records` با `record_number = NULL` در DB پیدا/بساز، دو بار `GET /api/v1/patient/{uuid}` بزن، هر دو بار شمارهٔ یکسان برگردد.
---
### ۷. پنل ادمین — صفحهٔ تنظیمات + فرم پرونده
**الف) صفحهٔ تنظیمات** `assets/admin/pages/RecordNumberSettingsPage.tsx`:
- داخل `SettingsLayout` مثل بقیهٔ صفحات تنظیمات.
- سوییچ «شماره‌گذاری خودکار پرونده»، ورودی الگو، `SearchableSelect` برای سیاست ریست (**نه** `<select>` بومی — قاعدهٔ پروژه)، و یک خط پیش‌نمایشِ زنده: «شمارهٔ بعدی: MD-05-0013».
- راهنمای کوتاه توکن‌ها زیر فیلد.
- وقتی `can_edit === false`: فرم read-only + یک خط توضیح که فقط صاحب حساب می‌تواند تغییر دهد.
- route در `App.tsx` با `RoleRoute roles={['doctor','clinic','secretary']} blockClinicScope permission={['patients','view']}` و آیتم در `settingsMenu.ts` (کلید `record-number`، آیکون `HashtagIcon`، همان `perm`).
**ب) فرم پرونده** `PatientRecordFormPage.tsx`:
- وقتی الگو فعال است و کاربر اجازهٔ دستی ندارد: فیلد `record_number` **در حالت ساخت** پنهان/read-only شود و قاعدهٔ zod از `min(1)` به اختیاری تغییر کند — وگرنه فرم با فیلدی که کاربر نمی‌تواند پر کند قفل می‌شود. در حالت ویرایش، رفتار فعلی برای صاحب حساب می‌ماند.
- مقدار الگو/دسترسی از همان `GET /api/v1/patient-record-number-settings` (یک هوک `useRecordNumberSettings` در `assets/admin/hooks/`).
**نحوه تست:** `npx tsc --noEmit` + `npx vitest run assets/admin/pages/RecordNumberSettingsPage.test.tsx` با سه سناریو: ذخیرهٔ الگو، پیش‌نمایش درست، و read-only بودن فرم وقتی `can_edit === false`. برای فرم پرونده یک تست که با الگوی فعال، ارسال بدون `record_number` را مجاز بداند.
---
### ۸. مستندات
- `docs/api/patient.md`: بخش تازه برای دو endpoint تنظیمات (method/path/permission، بدنهٔ کامل request، JSON واقعیِ response از اجرای واقعی، همهٔ کدهای خطا) + به‌روزرسانی توضیح `record_number` در `POST /api/v1/patient` و `PATCH /patient/{uuid}` (چه کسی می‌تواند دستی بفرستد، چه زمانی سرور تولید می‌کند).
- در همان فایل بنویس که `GET /api/v1/patient/{uuid}` ممکن است شمارهٔ پرونده را تخصیص دهد (اثر جانبیِ عمدی روی یک `GET` — سند بدون این، رفتار را غافلگیرکننده می‌کند).
## نکات مهم
- **الگوی طراحی:** یک سرویسِ تکی (`RecordNumberGenerator`) کافی است؛ Strategy برای «انواع الگو» نساز — الان فقط یک زبانِ توکن وجود دارد و abstraction دوم مصرف ندارد (guidelines §۵). ریست‌ها فقط سه مقدار ثابت‌اند و با یک `match` روی `periodKey()` حل می‌شوند، نه سه کلاس.
- **جداسازی محیط:** جدول جدید `entity_type/entity_id` دارد، پس یا `TenantOwnedTrait` بگیرد یا با دلیل در `GlobalTables` ثبت شود — `TenantSchemaCoverageTest` در غیر این صورت قرمز می‌شود. (مسیر درست: `TenantOwnedTrait`.)
- **شمارنده روی همان ردیفِ تنظیمات است**، نه `MAX(record_number)+1`. شمارش از روی مقادیر موجود، با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو می‌شکند.
- **گیتِ ورود دستی سمت سرور اجباری است.** پنهان‌کردن فیلد در UI کافی نیست؛ منشی می‌تواند مستقیم به API بزند.
- **رفتار قبلی نباید بشکند:** محیطی که الگو ندارد یا `enabled=false` است باید دقیقاً مثل امروز کار کند (ورود دستی آزاد، شماره تولید نشود). این را تست کن.
- **`record_number` در جستجوی بیماران استفاده می‌شود** (`/api/v1/patients?search=`, placeholder «جستجوی نام، شماره تماس، شماره پرونده...»). بعد از این تغییر، جستجو با شمارهٔ تولیدشده را دستی امتحان کن.
- **مصرف‌کنندهٔ دیگر ندارد:** `nobat724_front` و `clinic-pro-tauri` این فیلد را نمی‌خوانند (grep شد)، پس تغییر cross-repo لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.
@@ -0,0 +1,370 @@
# تب «نوبت‌های بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل
## پروژه
`clinicpro` (backend + پنل ادمین). cross-repo نیست.
## زمینه
درمان چندجلسه‌ای امروز فقط از یک صفحهٔ سراسری دیده می‌شود:
`/admin/treatment-cases`. آنجا فهرست همهٔ پرونده‌های محیط است، نه پروندهٔ یک بیمار.
نتیجه: منشی که پروندهٔ یک بیمار را باز کرده (`/admin/patients/{uuid}`) هیچ راهی ندارد
ببیند این بیمار چه دوره‌هایی دارد، جلسهٔ بعدش کِی است، و جلسات قبلی چه چیزی ثبت کرده‌اند.
`TreatmentScheduler` هم فقط سررسید **جلسهٔ بعدی** را می‌نویسد و بقیه `null` می‌مانند
(تصمیم عمدی؛ کامنت خودِ کلاس). پس هیچ‌جا نمی‌شود کل تقویم یک دوره را دید.
## مشکل / هدف
سه چیز:
1. **تب «نوبت‌های بعدی» در پروندهٔ بیمار.** دوره‌های همان بیمار، و برای هر دوره کارتِ
همهٔ جلسات با تاریخ و ساعتِ محاسبه‌شده. هر کارت دکمهٔ «ثبت نوبت» دارد که همان مودال
`NewAppointmentModal` را با همان تاریخ/ساعت باز می‌کند و کاربر می‌تواند تاریخ و ساعت
دیگری هم بگذارد.
2. **دیدن جزئیات انجام‌شده.** برای جلسات تمام‌شده باید معلوم باشد چه کسی، روی چه
ناحیه‌ای، با چه دستگاهی و با چه خوانده‌هایی (انرژی/پالس/شات) کار کرده.
3. **واژهٔ «اپراتور» → «پرسنل»** در رشته‌های کاربرپسند، و پیش‌فرض شدن پرسنلِ پروتکل در
مودال ثبت نوبت.
---
## ⚠ تصمیم معماری که باید قبل از کد روشن باشد
خواستهٔ «همهٔ نوبت‌ها را بر اساس طول درمان محاسبه کن» با یک تصمیم ثبت‌شدهٔ پروژه در
تضاد ظاهری است. متن خودِ `TreatmentScheduler`:
> فقط جلسهٔ **بعدی** بازمحاسبه می‌شود. جلسات دورتر حدسِ قبلی‌شان را نگه می‌دارند تا
> نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبه‌اش دقیق‌ترش نمی‌کند.
**راه‌حل انتخابی: محاسبه بشود ولی ذخیره نشود.**
- یک سرویس **فقط‌خواندنی** تقویم کل دوره را از روی آخرین لنگرِ واقعی می‌سازد.
- `TreatmentSession.due_at` دست‌نخورده می‌ماند و همچنان فقط جلسهٔ بعدی را نگه می‌دارد.
- در UI، جلسه‌ای که `due_at` واقعی دارد با جلسه‌ای که فقط تخمین است **متفاوت** نشان
داده می‌شود.
**چرا این و نه ذخیره کردن همه:** اگر همهٔ سررسیدها ذخیره شوند، هر بار که بیمار دیر
می‌آید باید کل زنجیره بازنویسی شود و هر نسخهٔ ذخیره‌شده یک ادعای غلط دربارهٔ آینده است.
تخمینِ محاسبه‌شده در لحظهٔ نمایش، همیشه با آخرین واقعیت هم‌خوان است و چیزی برای
نگه‌داشتن ندارد.
**ریسک این انتخاب:** تاریخ‌های کارت با هر بار باز کردن صفحه ممکن است عوض شوند (اگر
جلسه‌ای بین دو بازدید تمام شده باشد). این پذیرفته است و باید در UI با برچسب «تخمینی»
اعلام شود.
**گزینهٔ جایگزینی که رد شد:** ساختن نوبت‌های `pending` برای کل دوره. رد شد چون جای
دستگاه را می‌گیرد و بیمارِ نیامده وقت منبع را می‌سوزاند — همان دلیلی که «رزرو خودکار»
قبلاً رد شده بود.
---
## معیار پذیرش
- ✅ موفق: `GET /api/v1/treatment-cases?record=<record_uuid>` با توکن منشی → ۲۰۰ و فقط
پرونده‌های همان بیمار. `GET /api/v1/treatment-case/{uuid}/plan` → ۲۰۰ با آرایهٔ
جلسات که هر کدام `planned_at` و `is_estimate` دارند.
- ✅ موفق: در `/admin/patients/{uuid}?tab=treatment` کارت هر جلسه تاریخ و ساعت شمسی
نشان می‌دهد و دکمهٔ «ثبت نوبت» مودال `NewAppointmentModal` را با همان تاریخ/ساعت
و همان بیمار و سرویس باز می‌کند.
- ✅ موفق: در مودال ثبت نوبت، وقتی سرویسی با پروتکل فعال انتخاب می‌شود، فیلد «پرسنل»
از `TreatmentProtocol.staff` پیش‌فرض پر می‌شود.
- ❌ خطا: `GET /api/v1/treatment-case/{uuid}/plan` برای پروندهٔ محیط دیگر → ۴۰۴ با
envelope خطا. `?record=` با uuid ناموجود → آرایهٔ خالی، نه ۵۰۰.
- ⚠️ مرزی: بیمارِ بدون هیچ دوره → تب «نوبت‌های بعدی» حالت خالیِ طراحی‌شده دارد، نه
اسکلتون بی‌پایان.
- ⚠️ مرزی: دوره‌ای که همهٔ جلساتش تمام شده → کارت‌ها همه «انجام‌شده» با جزئیات، و هیچ
دکمهٔ «ثبت نوبت» فعالی ندارند.
- ⚠️ مرزی: جلسه‌ای که نوبت دارد → دکمه‌اش «ثبت نوبت» نیست؛ نوبت موجود نشان داده می‌شود.
- ⚠️ مرزی: پروتکلی که هیچ پرسنلی ندارد → فیلد «پرسنل» خالی می‌ماند و فرم قفل نمی‌شود.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Treatment/Controller/TreatmentCaseController.php` | فهرست و جزئیات پرونده؛ اندپوینت‌های جدید اینجا |
| `src/Treatment/Repository/TreatmentCaseRepository.php` | `findForTenant` — فیلتر `record` اینجا اضافه می‌شود |
| `src/Treatment/Service/TreatmentScheduler.php` | محاسبهٔ سررسید؛ **دست‌نخورده می‌ماند** |
| `src/Treatment/Entity/TreatmentCase.php` | `toArray(withSessions)` — امروز نواحی را نمی‌فرستد |
| `src/Treatment/Entity/TreatmentSession.php` | `toArray(withAreas)` |
| `src/Treatment/Entity/SessionAreaRecord.php` | خوانده‌های دستگاه — منبع «ورک‌فلوی انجام‌شده» |
| `assets/admin/pages/PatientDetailPage.tsx` | تب‌های پروندهٔ بیمار |
| `assets/admin/components/appointments/NewAppointmentModal.tsx` | مودال ثبت نوبت |
| `assets/admin/components/TreatmentCaseEditModal.tsx` | لیبل «اپراتور» |
| `assets/admin/pages/TreatmentCasesPage.tsx` | رشتهٔ «اپراتور:» |
| `assets/admin/pages/StaffSessionDetailPage.tsx` | رشتهٔ «اپراتور:» |
| `docs/api/treatment.md` | سند اندپوینت‌ها |
---
## وضعیت فعلی
### فیلتر پرونده بر اساس بیمار وجود ندارد
`TreatmentCaseRepository::findForTenant` فقط محیط، وضعیت، جستجو و بازهٔ تاریخ دارد:
```php
public function findForTenant(
string $entityType,
int $entityId,
?string $status = null,
?string $q = null,
?int $openedFrom = null,
?int $openedTo = null,
): array {
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
// ...
```
### جزئیات پرونده نواحی جلسات را نمی‌فرستد
`TreatmentCase::toArray()`:
```php
if ($withSessions) {
$data['sessions'] = array_map(
static fn (TreatmentSession $s): array => $s->toArray(),
$this->sessions->toArray(),
);
}
```
`TreatmentSession::toArray(bool $withAreas = false)` نواحی را فقط با آرگومان می‌دهد و
اینجا فرستاده نمی‌شود. پس «چه کاری روی چه ناحیه‌ای انجام شد» در پاسخ نیست.
### تب‌های پروندهٔ بیمار
```tsx
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
// ...
];
```
### فیلد پرسنل در مودال ثبت نوبت — بدون پیش‌فرض
```tsx
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد،
// پر کردنش آن را از قبل به یک نفر می‌دهد.
const [staffUuid, setStaffUuid] = useState('');
```
```tsx
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>
```
### پروتکل، پرسنل مجاز را از قبل می‌فرستد
`TreatmentProtocol::toArray()`:
```php
'staff' => array_map(
static fn (TreatmentProtocolStaff $m): array => $m->toArray(),
$this->allowedStaff->toArray(),
),
```
یعنی دادهٔ لازم برای پیش‌فرض هست؛ فقط مصرف نمی‌شود.
---
## وظایف
### ۱. فیلتر پرونده بر اساس بیمار
به `findForTenant` پارامتر `?string $recordUuid = null` اضافه کن و در کنترلر از
`?record=` بخوان.
```php
if ($recordUuid !== null && $recordUuid !== '') {
$qb->join('c.patientRecord', 'prf')
->andWhere('prf.uuid = :record')
->setParameter('record', $recordUuid);
}
```
> اگر `q` هم فرستاده شده باشد، `patientRecord` قبلاً join شده — از alias تکراری
> پرهیز کن (یک join مشترک، نه دو تا).
**نحوه تست:**
```bash
TOKEN=... # 09390039833 / QaTest@1234
curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-cases?record=<record_uuid>" \
-H "Authorization: Bearer $TOKEN"
```
باید فقط پرونده‌های همان بیمار برگردد. با `record=` نامعتبر → آرایهٔ خالی.
تست PHPUnit در `tests/Treatment/TreatmentCaseEditTest.php` کنار تست‌های `findForTenant`.
---
### ۲. سرویس تقویم دوره (فقط‌خواندنی)
`src/Treatment/Service/TreatmentPlanProjector.php`
مسئولیت واحد: از روی جلسات یک پرونده، تاریخ **همهٔ** جلسات را بساز.
قاعده:
- جلسهٔ انجام‌شده → `planned_at = finished_at`، `is_estimate = false`
- جلسه‌ای که نوبت دارد → `planned_at = appointment.slot_start`، `is_estimate = false`
- جلسه‌ای که `due_at` دارد → `planned_at = due_at`، `is_estimate = false`
- بقیه → از آخرین لنگر به بعد، با جمع زدن `offset_days` گام‌ها، `is_estimate = true`
```php
final class TreatmentPlanProjector
{
private const DAY = 86400;
/** @return list<array{session: TreatmentSession, planned_at: ?int, is_estimate: bool}> */
public function project(TreatmentCase $case): array
{
// جلسات به ترتیب شماره؛ لنگر = آخرین زمان قطعیِ دیده‌شده
// برای هر جلسهٔ بی‌لنگر: anchor += offsetDays(stepNumber) * DAY
}
}
```
**چرا سرویس جدا و نه متد روی `TreatmentScheduler`:** آن کلاس *می‌نویسد* و این فقط
*می‌خواند*. قاطی کردنشان یعنی یک کلاس با دو مسئولیت و ریسک اینکه تخمین اشتباهی
ذخیره شود (guidelines §۵، اصل S).
**نحوه تست:** تست واحد در `tests/Treatment/` — پرونده‌ای با ۴ جلسه بساز، جلسهٔ ۱ را
تمام کن، و بررسی کن جلسهٔ ۲ `is_estimate=false` (چون `due_at` گرفته) و جلسات ۳ و ۴
`is_estimate=true` با فاصلهٔ درستِ گام‌هایشان باشند. حالت مرزی: پرونده‌ای که هیچ جلسهٔ
تمام‌شده‌ای ندارد و نوبت هم ندارد → همه `is_estimate=true` با لنگرِ `opened_at`.
---
### ۳. اندپوینت تقویم + جزئیات انجام‌شده
`GET /api/v1/treatment-case/{uuid}/plan`
پاسخ برای هر جلسه: `uuid`، `session_number`، `status`، `planned_at`، `is_estimate`،
`appointment` (اگر دارد)، `performed_by`، و **`areas`** با `parameters`، `resource`،
`note`، `started_at`، `finished_at`.
نواحی همان چیزی است که کاربر «ورک‌فلوی انجام‌شده» می‌نامد. `SessionAreaRecord::toArray()`
از قبل همه را دارد؛ فقط باید `withAreas: true` فرستاده شود.
**تصمیم:** اندپوینت جدا از `GET /treatment-case/{uuid}` باشد، نه اضافه کردن به آن.
دلیل: پاسخ فعلی مصرف‌کنندهٔ دیگری دارد (مودال ویرایش) که نواحی را لازم ندارد و
بزرگ‌ترش کردن یعنی هزینهٔ بی‌مصرف روی همان مسیر.
**نحوه تست:**
```bash
curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid>/plan" -H "Authorization: Bearer $TOKEN"
```
باید همهٔ جلسات با `planned_at` بیایند و جلسهٔ انجام‌شده `areas[].parameters` داشته باشد.
پروندهٔ محیط دیگر → ۴۰۴.
---
### ۴. تب «نوبت‌های بعدی» در پروندهٔ بیمار
`TabKey` را با `'treatment'` گسترش بده و به `TABS` اضافه کن.
کامپوننت جدید `assets/admin/components/patient/PatientTreatmentTab.tsx`:
- `GET /api/v1/treatment-cases?record={uuid}` → فهرست دوره‌ها
- برای دورهٔ باز، `GET /api/v1/treatment-case/{uuid}/plan`
- هر جلسه یک کارت: شمارهٔ جلسه، تاریخ و ساعت شمسی (`formatDateTime`)، وضعیت با
`StatusBadge type="treatment-session"`، و برچسب «تخمینی» وقتی `is_estimate`
- جلسهٔ انجام‌شده: پرسنل + نواحی + خوانده‌های دستگاه
- جلسهٔ بدون نوبت: دکمهٔ «ثبت نوبت»
**دکمهٔ «ثبت نوبت» باید همان `NewAppointmentModal` را باز کند** — نه صفحهٔ جدید و نه
مودال تازه. همان مودالی که در `/admin/appointments?resource=…` استفاده می‌شود.
مودال این propها را می‌گیرد: `slot`، `services`، `date`، `clinicUuid`، `resource`.
برای اینکه تاریخ و ساعتِ کارت پیش‌فرض شود، `date` را از `planned_at` بده.
> **این را قبل از کد بررسی کن:** مودال امروز `treatment_session_uuid` را نمی‌فرستد
> (فقط صفحهٔ `AppointmentCreatePage` می‌فرستد). برای اینکه نوبت به همان جلسه بچسبد،
> باید prop تازه‌ای مثل `treatmentSessionUuid` به مودال اضافه شود و در payload برود.
> بدون آن، اتصال دوباره به حدسِ سرویس می‌افتد — همان چیزی که در
> `SessionBookingLink` صریح شد.
**نحوه تست UI:**
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/patients/<uuid>?tab=treatment" \
--out /tmp/plan.png --full --wait 5000
```
سناریو: بیماری با دورهٔ باز → کارت‌ها دیده شوند؛ کلیک روی «ثبت نوبت» → مودال با تاریخ
همان کارت باز شود؛ ثبت → جلسه `booked` شود و کارت به‌روز شود.
---
### ۵. پیش‌فرض شدن پرسنلِ پروتکل در مودال ثبت نوبت
در `NewAppointmentModal`، وقتی `pick.serviceUuids` تغییر می‌کند، پروتکل سرویس اول را
بخوان و اگر `staff` دارد و کاربر هنوز دستی چیزی انتخاب نکرده، اولین پرسنل را بگذار.
```tsx
const protocolQ = useQuery({
queryKey: ['service-protocol', pick.serviceUuids[0]],
queryFn: () => api.get(`/api/v1/service-item/${pick.serviceUuids[0]}/treatment-protocol`),
enabled: pick.serviceUuids.length > 0,
});
```
**قاعده:** فقط وقتی پیش‌فرض بگذار که کاربر دست نزده باشد (یک فلگ `staffTouched`).
وگرنه انتخاب دستیِ منشی با هر تغییر سرویس پاک می‌شود.
**نحوه تست:** vitest در `assets/admin/components/appointments/ResourceBookingModal.test.tsx`
mock کردن پاسخ پروتکل با یک پرسنل، انتخاب سرویس، و بررسی اینکه `staff_uuid` در payload
همان است. حالت مرزی: پروتکل بدون پرسنل → فیلد خالی و ثبت همچنان ممکن.
---
### ۶. «اپراتور» → «پرسنل»
فقط رشته‌های کاربرپسند. چهار مورد:
| فایل | خط | رشته |
|------|----|------|
| `assets/admin/components/appointments/NewAppointmentModal.tsx` | ۲۸۴ | `اپراتور (اختیاری)` |
| `assets/admin/components/TreatmentCaseEditModal.tsx` | ۲۱۲ | `اپراتور` |
| `assets/admin/pages/TreatmentCasesPage.tsx` | ۳۵۷ | `اپراتور: …` |
| `assets/admin/pages/StaffSessionDetailPage.tsx` | ۱۱۶ | `اپراتور: …` |
**دست نزن به** `assets/admin/pages/ResourcesPage.tsx:107`:
```tsx
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
```
آنجا «اپراتور» یک **نوع منبع** است، نه رکورد `ClinicStaff`. عوض کردنش معنی جمله را
خراب می‌کند. اگر مطمئن نیستی، از کاربر بپرس.
کامنت‌های کد را هم می‌توانی هماهنگ کنی ولی اولویت ندارد.
**نحوه تست:** `npx vitest run` — تستی که به متن «اپراتور» تکیه کرده باشد باید به‌روز
شود؛ بعد grep بزن که هیچ رشتهٔ کاربرپسندِ «اپراتور» جز مورد `ResourcesPage` نمانده باشد.
---
## نکات مهم
- **`TreatmentScheduler` تغییر نمی‌کند.** تقویم تخمینی فقط خوانده می‌شود و `due_at`
هیچ‌وقت از این مسیر نوشته نمی‌شود. اگر خودت را در حال `setDueAt` دیدی، از مسیر
خارج شده‌ای.
- الگوی به‌کاررفته: **Projection/Read Model** — یک سرویس فقط‌خواندنی که از داده‌های
موجود یک نمای مشتق می‌سازد. دلیل انتخاب: نما با هر تغییرِ واقعیت خودش به‌روز است و
هیچ داده‌ای برای همگام‌سازی ندارد.
- سه حالت داده (Loading / Empty / Error) برای تب جدید اجباری است — الگوی موجود در
`TreatmentCasesPage.tsx` را ببین: خطای سرور نباید «دوره‌ای ندارد» خوانده شود.
- ارقام با `formatNumber` و تاریخ‌ها با `formatDateTime` (شمسی). ارقام لاتین وسط متن
فارسی، نقصِ تکرارشوندهٔ این صفحه‌ها بوده.
- وضعیت تب در URL است (`?tab=treatment`) — `PatientDetailPage` از قبل `searchParams`
را می‌خواند.
- بعد از هر تغییر API، `docs/api/treatment.md` در همان جلسه به‌روز شود.
- تنانسی: هر دو اندپوینت جدید باید از `requireCase`/`branches->pair($user)` عبور کنند؛
پروندهٔ محیط دیگر ۴۰۴ می‌گیرد نه ۴۰۳.
- migration لازم **نیست** — هیچ Entity تغییر نمی‌کند.
@@ -0,0 +1,412 @@
# نوبت‌دهی آنلاین منبع‌محور — اندپوینت‌های عمومی
## پروژه
`clinicpro` (Backend).
پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/public-resource-booking-ui.md`.
**اول این را اجرا کن، بعد آن را.** قرارداد API که اینجا ساخته می‌شود مصرف‌کنندهٔ مستقیم دارد و
تغییرش در build سایت خطا نمی‌دهد.
## زمینه
منبع (`ClinicResource`) هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
هر منبع تقویم خودش را دارد و سرویس‌هایی که ارائه می‌دهد در `resource_service_offerings` ثبت شده‌اند
(`ResourceServiceOffering`)، با مدت و قیمتِ اختصاصیِ همان جفتِ منبع↔سرویس.
روی `ServiceItem` یک توگل به نام `bookable` هست که در پنل با برچسب «نمایش در نوبت‌دهی آنلاین»
دیده می‌شود:
```php
// src/ClinicService/Entity/ServiceItem.php:107
/** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
```
امروز این توگل فقط جریانِ **پزشک‌محورِ** سایت را تغذیه می‌کند
(`AppointmentController::bookableServices()``ServiceItemRepository::findBookableByEntity()`).
هیچ مسیر عمومی‌ای منابع را نمی‌بیند.
## مشکل / هدف
اسلات‌های منبع فقط از داخل پنل قابل خواندن‌اند:
```php
// src/Resource/Controller/ResourceBookingSlotController.php:26
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ResourceBookingSlotController extends BaseController
{
use ResourcePermissionTrait;
// هر اکشن با denyUnlessGrantedForBooking($user) گیت می‌شود
```
`ResourceContext::resource($user, $uuid)` هم منبع را در محیطِ کاربرِ احرازشده حل می‌کند، پس برای
بازدیدکنندهٔ ناشناسِ سایت اصلاً قابل استفاده نیست.
هدف: سه اندپوینت عمومیِ جدید تا سایت بتواند
۱) منابعِ قابلِ رزروِ یک پزشک را ببیند، ۲) وقت‌های خالیِ یک منبع برای سرویس‌های انتخاب‌شده را
بگیرد، ۳) روزهای فعالِ ماه را برای تقویم بگیرد.
به‌علاوه رفع یک ناسازگاریِ موجود در مسیر عمومیِ ثبت نوبت (وظیفهٔ ۴).
**گیتِ عمومی‌شدن دقیقاً همان توگل است:** منبع وقتی در سایت دیده می‌شود که دست‌کم یک
`ResourceServiceOffering` فعال به یک `ServiceItem` با `bookable = true` و `active = true` داشته باشد.
**تصمیم دامنه (تأییدشده):** روی صفحهٔ یک پزشک فقط منابعی می‌آیند که پزشکِ ناظرشان
(`supervisor`) همان پزشک است، یا خودشان پلِ همان پزشک‌اند (`doctor_id`). فهرست کلینیک‌محور
خارج از این تسک است.
## معیار پذیرش
- ✅ موفق:
- `GET /api/v1/appointment-booking-resources/{doctorUuid}` بدون هیچ توکنی → ۲۰۰ با
`{ success: true, data: { doctor_uuid, clinic_uuid, resources: [...] } }`؛ هر منبع فیلد
`services[]` دارد و در آن فقط سرویس‌های `bookable=true` و `active=true` با
`duration_minutes` و `price_rials`ِ حل‌شده از `ResourceServiceResolver` هستند.
- `GET /api/v1/appointment-resource-slots?resource_uuid=..&date=..&service_item_uuids[]=..`
بدون توکن → ۲۰۰ با `start_times[]` که هر عضوش `{start, end, start_time, end_time}` است، و
`total_duration_minutes` برابرِ مجموعِ مدتِ حل‌شدهٔ همان منبع.
- `GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=`
بدون توکن → ۲۰۰ با `enabled_dates[]` و `disabled_dates[]` که مجموعشان همهٔ روزهای آن ماه است.
- `POST /api/v1/appointment` با `resource_uuid` + `service_item_uuids[]` + یکی از
`start_times`ِ بالا → ۲۰۱، و `slot_end` دقیقاً برابرِ `end`ِ همان start_time.
- ❌ خطا:
- منبعی که هیچ سرویسِ `bookable` ندارد → در پاسخِ فهرست **نمی‌آید**؛ و
`appointment-resource-slots` رویش → ۴۲۲ با کد `ERR_VALIDATION_001` و
`field: service_item_uuids`.
- سرویسی که `bookable=false` است ولی روی منبع offering فعال دارد → `resource-slots` → ۴۲۲
(پیام: «این سرویس برای نوبت‌دهی آنلاین فعال نیست»). یعنی مسیر عمومی سخت‌گیرتر از پنل است.
- `date` با فرمت نادرست → ۴۲۲ با `field: date`. `resource_uuid` ناموجود یا `active=false`
۴۲۲ با `field: resource_uuid` (نه ۴۰۴؛ همان الگوی موجود در `AppointmentController`).
- `POST /api/v1/appointment` روی بازه‌ای که منبع در آن پر است → ۴۰۹ با
`field: resource_uuid` (رفتار موجودِ `ResourceOccupier`؛ نباید بشکند).
- ⚠️ مرزی:
- پزشکی که هیچ منبعی ندارد → ۲۰۰ با `resources: []`، نه ۴۰۴.
- منبعِ با `capacity > 1`: وقتی یک نوبت روی آن نشسته، همان بازه هنوز باید در `start_times` بیاید
(ظرفیت هنوز پر نشده). `ResourceBookingSlotService::freeIntervals()` این را می‌داند؛ فقط با
داده تست شود.
- روزی که منبع شیفت ندارد → `start_times: []` و همان روز در `disabled_dates` ماه.
- `service_item_uuids` خالی → ۴۲۲ با پیام «انتخاب حداقل یک سرویس الزامی است» (رفتار موجودِ
`resolveDuration`).
- آخرین روز ماه شمسی/میلادی: `month-availability` بر پایهٔ سال/ماهِ **میلادی** است — همان
قرارداد `appointment-settings/month-availability` که سایت از قبل با آن کار می‌کند. تغییرش نده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Resource/Entity/ClinicResource.php` | منبع؛ `address`, `type`, `supervisor`, `doctor`, `capacity`, `active` |
| `src/Resource/Entity/ResourceServiceOffering.php` | جفتِ منبع↔سرویس با مدت/قیمت/`active` |
| `src/Resource/Repository/ClinicResourceRepository.php` | کوئری‌های منبع؛ متد جدید اینجا |
| `src/Resource/Repository/ResourceServiceOfferingRepository.php` | `findForResource`, `bookableResourceIds`, `activeResourceIdsFor` |
| `src/Resource/Service/ResourceBookingSlotService.php` | `resolveDuration`, `startTimes`, `freeIntervals`, `assertOffered` |
| `src/ClinicService/Service/ResourceServiceResolver.php` | زنجیرهٔ حلِ مدت و قیمت |
| `src/Resource/Controller/ResourceBookingSlotController.php` | نسخهٔ پنلیِ همین اسلات‌ها — الگوی مرجع |
| `src/Appointment/Controller/AppointmentController.php` | اندپوینت‌های عمومی موجود + `POST /api/v1/appointment` |
| `config/packages/security.yaml` | `access_control` |
| `docs/api/resource.md`، `docs/api/appointment.md` | مستندات |
## وضعیت فعلی
منبعِ حقیقتِ مدت و قیمت، این زنجیره است:
```php
// src/ClinicService/Service/ResourceServiceResolver.php:41
public function resolve(
ClinicResource $resource,
ServiceItem $item,
DoctorAddress $address,
?ServiceItem $parentService = null,
): ResolvedServiceSpec
// ۱. منبع+گزینه → ۲. منبع+سرویس → ۳. شعبه → ۴. پیش‌فرض سرویس
```
و اسلات‌ها:
```php
// src/Resource/Service/ResourceBookingSlotService.php:87
public function startTimes(
ClinicResource $resource,
string $date,
int $totalMinutes,
?int $excludeAppointmentId = null,
): array
```
مسیر عمومیِ ثبت نوبت، مدت را از calculatorِ پزشک‌محور می‌گیرد — حتی وقتی منبع دارد:
```php
// src/Appointment/Controller/AppointmentController.php:528-538
$duration = null;
if ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
if ($resource !== null) {
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
// ...
```
مسیر پنل همین را درست انجام می‌دهد و باید الگو باشد:
```php
// src/Appointment/Controller/MyAppointmentsController.php:188-197
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع می‌آید (هر دستگاه مدت خودش را
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` می‌نشیند.
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
```
## وظایف
### ۱. کوئری منابعِ قابلِ رزروِ عمومی
در `src/Resource/Repository/ClinicResourceRepository.php` متد جدید:
```php
/**
* منابعِ یک پزشک در یک محیط که در سایت عمومی قابل رزروند.
*
* سه شرط با هم: منبع فعال، دست‌کم یک offering فعال، و سرویسِ آن offering هم
* `bookable` و هم `active`. توگلِ «نمایش در نوبت‌دهی آنلاین» تنها گیتِ عمومی‌شدن است؛
* منبعی که سرویسِ روشنی ندارد اصلاً نباید در پاسخ دیده شود.
*
* @return ClinicResource[]
*/
public function findPublicBookableForDoctor(string $entityType, int $entityId, Doctor $doctor): array
{
return $this->createQueryBuilder('r')
->join('r.type', 't')
->addSelect('t')
->join(ResourceServiceOffering::class, 'o', 'WITH', 'o.resource = r')
->join('o.serviceItem', 'i')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->andWhere('r.active = true')
->andWhere('o.active = true')
->andWhere('i.bookable = true')
->andWhere('i.active = true')
// فقط منابع همین پزشک: یا خودش پلِ پزشک است، یا پزشک ناظرش همین است.
->andWhere('r.doctor = :doctor OR r.supervisor = :doctor')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('doctor', $doctor)
->distinct()
->orderBy('r.name', 'ASC')
->getQuery()
->getResult();
}
```
**نحوه تست:** یک unit/functional تست که سه سناریو بسازد — منبع با سرویسِ bookable (باید بیاید)،
منبع با سرویسِ `bookable=false` (نباید بیاید)، منبع با offering غیرفعال (نباید بیاید) — و منبعی
که ناظرش پزشک دیگری است (نباید بیاید).
### ۲. سرویسِ ساختِ payload عمومی
فایل جدید `src/Resource/Service/PublicResourceBookingService.php`.
کنترلر نازک می‌ماند؛ این کلاس تنها مسئولیتش «منبع → آرایهٔ عمومی» است (SRP).
```php
/**
* نمای عمومیِ منبع برای سایت نوبت‌دهی.
*
* از نمای پنل جداست چون سؤالِ دیگری جواب می‌دهد: پنل همهٔ سرویس‌های منبع را
* می‌خواهد، سایت فقط آن‌هایی را که مالک روشن کرده. ادغامشان یعنی یک `if ($public)`
* در دلِ کد پنل و یک راهِ تازه برای نشتِ سرویسِ خاموش.
*/
final class PublicResourceBookingService
{
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ResourceServiceResolver $resolver,
) {}
/** @return array<int, array<string, mixed>> */
public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array;
/**
* سرویس‌های عمومیِ یک منبع — همان گیتِ فهرست، تا اسلات و فهرست از هم واگرا نشوند.
*
* @return array<int, array<string, mixed>>
*/
public function publicServices(ClinicResource $resource): array;
/** @throws AppException ۴۲۲ روی سرویسی که در سایت روشن نیست */
public function assertPublicService(ClinicResource $resource, ServiceItem $item): void;
}
```
شکل هر منبع در پاسخ:
```php
[
'uuid' => $resource->getUuid(),
'name' => $resource->getName(),
'type' => ['code' => $type->getCode(), 'name' => $type->getName()],
'capacity' => $resource->getCapacity(),
'location' => [
'uuid' => $address->getUuid(),
'title' => $address->getName() ?: 'مطب شخصی',
'address' => $address->getAddress(),
],
'supervisor' => ['uuid' => ..., 'full_name' => ...] | null,
'services' => [
[
'uuid' => $item->getUuid(),
'name' => $item->getName(),
'duration_minutes' => $spec->durationMinutes, // از resolver، نه از پیش‌فرض خام
'price_rials' => $spec->priceRials,
'service_section' => ['uuid' => ..., 'name' => ...],
],
],
]
```
نکتهٔ کارایی: `publicServices()` نباید به ازای هر سرویس یک `findOneFor` جدا بزند وقتی
`findForResource($resource)` همه را یک‌جا می‌دهد. offeringهای همان منبع را یک‌بار بخوان و
سرویس‌های `bookable && active` را از رویش فیلتر کن؛ `resolver->resolve()` را فقط برای همان‌ها صدا بزن.
**نحوه تست:** `ddev exec php bin/phpunit` روی یک تستِ سرویس با دو سرویس (یکی روشن، یکی خاموش)
و یک offering با `duration_minutes` اختصاصی؛ ادعا: خروجی یک عضو دارد و مدتش عددِ offering است،
نه `solo_duration_minutes`ِ سرویس.
### ۳. کنترلر عمومی + مسیرها
فایل جدید `src/Resource/Controller/PublicResourceBookingController.php``extends BaseController`،
**بدون** `IsGranted` و بدون `ResourcePermissionTrait`.
```php
/**
* نوبت‌دهی منبع‌محور برای سایت عمومی.
*
* از {@see ResourceBookingSlotController} جداست و نه یک پرچمِ `public` روی آن: آنجا منبع
* از محیطِ کاربرِ احرازشده حل می‌شود (`ResourceContext::resource($user, $uuid)`) و اینجا
* کاربری وجود ندارد. یک کنترلر با دو مدلِ اعتماد، همان‌جایی است که نشت اتفاق می‌افتد.
*/
#[OA\Tag(name: 'Resource')]
class PublicResourceBookingController extends BaseController
{
// GET /api/v1/appointment-booking-resources/{doctorUuid}?clinic_uuid=
// GET /api/v1/appointment-resource-slots?resource_uuid=&date=&service_item_uuids[]=
// GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=
}
```
قواعدی که باید رعایت شوند:
- محیط با همان الگوی موجود حل شود:
`[$type, $id] = EntityContext::forBooking($doctor, $clinic)->toEntityPair();`
و `$clinic` از `AppointmentController::bookingClinic()` — اگر متد `private` است، منطقش را
کپی نکن؛ یا در یک سرویس مشترک بگذار یا از `BookingContextResolver` استفاده کن. تصمیم و دلیلش
را در کامنت بنویس.
- در `resource-slots` و `month-availability` منبع با `ClinicResourceRepository::findByUuid()`
گرفته می‌شود و **باید** بررسی شود: `isActive()` و اینکه دست‌کم یک سرویس عمومی دارد. منبعِ
ناموجود یا خاموش → ۴۲۲ با `field: resource_uuid`.
- برای هر uuid در `service_item_uuids` اول `assertPublicService()` صدا زده شود (گیتِ `bookable`
بعد `ResourceBookingSlotService::resolveDuration()` (گیتِ offering + مدت). ترتیب مهم است:
پیامِ «در سایت فعال نیست» گویاتر از «این منبع این سرویس را ارائه نمی‌دهد» است.
- `durations[]` که نسخهٔ پنلی می‌پذیرد **در مسیر عمومی پذیرفته نشود** — override مدت ابزار منشی
است؛ در دست بازدیدکننده یعنی ساختن ظرفیتِ جعلی. یعنی `resolveDuration($resource, $uuids)`
بدون آرگومان سوم.
- `month-availability` روی روزهای ماه حلقه بزند و برای هر روز `startTimes(...) !== []` را
بسنجد — همان الگوی `AppointmentController::monthAvailability()` که با `hasAnyAvailability`
کار می‌کند. اگر روی ۳۱ روز کند بود، به‌جای `startTimes` از `freeIntervals` استفاده کن و فقط
وجودِ یک بازهٔ به‌اندازهٔ کافی بلند را چک کن.
سپس در `config/packages/security.yaml`، بخش `access_control`، کنار همتاهای موجود:
```yaml
- { path: ^/api/v1/appointment-booking-resources/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-slots, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-month-availability/, roles: PUBLIC_ACCESS }
```
مسیرها روی firewallِ `api` می‌مانند (به `public_endpoints` **اضافه نشوند**) — دقیقاً به همان
دلیلی که در کامنت بالای `public_endpoints` نوشته شده: توکن اختیاری بماند.
**نحوه تست:**
```bash
ddev exec php bin/console debug:router | grep resource
# سه مسیر جدید باید دیده شوند
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-booking-resources/<DOCTOR_UUID>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-slots?resource_uuid=<R>&date=2026-08-15&service_item_uuids[]=<S>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-month-availability/<R>?year=2026&month=8&service_item_uuids[]=<S>" | jq
```
هر سه بدون هدر `Authorization` و همه باید ۲۰۰ بدهند. سپس همان `curl`ها را روی سرویسی بزن که
`bookable` را در پنل خاموش کرده‌ای و ۴۲۲ بگیر.
اکانت تست پنل برای ساختن داده: `09390039833` / `09390039833`.
### ۴. رفعِ مدتِ نوبتِ منبع‌دار در مسیر عمومی
در `src/Appointment/Controller/AppointmentController.php::book()`، شاخهٔ محاسبهٔ مدت باید مثل
مسیر پنل، وقتی منبع هست از `ResourceBookingSlotService::resolveDuration()` استفاده کند:
```php
$duration = null;
$resourceItems = [];
if ($hasServices && $resource !== null) {
// مدت از زنجیرهٔ حلِ همان منبع می‌آید، وگرنه `slot_end` با start_timesِ
// appointment-resource-slots یکی نمی‌شود و بیمار وقتی را می‌گیرد که سرور
// جای دیگری آزاد حساب کرده بود.
foreach ($serviceUuids as $u) { /* assertPublicService(...) */ }
['minutes' => $minutes, 'items' => $resourceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids);
$slotEnd = $slotStart + $minutes * 60;
} elseif ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
```
سه قید:
- ترازِ سطلِ اشغال (`OccupancyBucket::alignWindow`) در خط ۵۱۵ **قبل از** محاسبهٔ مدت اجرا
می‌شود و در آنجا `$slotEnd` هنوز مقدارِ کلاینت است. بعد از بازنویسیِ `$slotEnd`، تراز باید
دوباره اعمال شود؛ وگرنه بازهٔ نهایی ناتراز می‌ماند.
- ذخیرهٔ سرویس‌ها روی نوبت (خط ~۶۰۵، `replaceServiceItems` + `setServiceDuration`) نباید بشکند.
در شاخهٔ منبع، `$duration` تهی است، پس این بلوک باید `items` و `minutes`ِ منبع را هم بپذیرد.
- بررسیِ موجودِ «این منبع این سرویس را ارائه می‌دهد؟» (خط ۵۵۸ با `hasAnyFor`/`activeResourceIdsFor`)
حالا با `resolveDuration` تکراری می‌شود. یکی را نگه دار — `resolveDuration` سخت‌گیرتر است چون
`offering.active` را هم می‌بیند. حذفِ کدِ تکراری را در همان commit توضیح بده.
**نحوه تست:** یک تست functional که:
۱) از `appointment-resource-slots` یک `start_time` بگیرد،
۲) با همان `start` و همان `service_item_uuids` روی `POST /api/v1/appointment` بزند،
۳) ادعا کند `slot_end` پاسخ برابر `end`ِ همان start_time است.
قبل از این تغییر باید قرمز شود.
### ۵. مستندات
- `docs/api/resource.md`: سه اندپوینت عمومی جدید با نمونهٔ درخواست/پاسخ و جدول خطاها.
- `docs/api/appointment.md`: رفتار `resource_uuid` در `POST /api/v1/appointment` — که مدت از
منبع می‌آید و در مسیر عمومی سرویس باید `bookable` باشد.
- اگر فایل `docs/api/appointment-booking.md` جریان عمومی را توصیف می‌کند، مرحلهٔ منبع‌محور را
هم آنجا اضافه کن.
## نکات مهم
- **گیتِ عمومی یک جا تعریف شود.** «سرویس در سایت دیده می‌شود» = `bookable && active` + offering
فعال. این شرط در سه جا لازم است (فهرست، اسلات، ثبت نوبت). یک متد در
`PublicResourceBookingService` و صدا زدنش از هر سه — نه سه‌بار نوشتنِ همان `if`.
- **جداسازی محیط:** منبع با uuid از بیرون می‌آید و `TenantFilter` پوششش نمی‌دهد
(`ResourceServiceOffering` فرزندِ aggregate است). در هر سه اندپوینت، تطابقِ
`(entity_type, entity_id)`ِ منبع با محیطِ رزروِ همان پزشک/کلینیک باید صریح بررسی شود — همان
کاری که `AppointmentController` در خط ۵۴۹ می‌کند.
- **fail-safe عمومی:** پارامتر `management=1` در این کنترلر معنا ندارد و پیاده نشود. اگر روزی
پنل بخواهد همین دید را داشته باشد، مسیر پنلیِ خودش را دارد.
- **`durations[]` در مسیر عمومی ممنوع** — دلیلش بالا آمده.
- ظرفیت و اشغال را خودت حساب نکن؛ `ResourceBookingSlotService` هر دو منبعِ اشغال را می‌بیند
(`appointments.resource_id` و `resource_occupancy`). دور زدنش یعنی نوبتِ نامرئی.
- تاریخ‌ها Unix timestamp صحیح‌اند؛ `start_time`/`end_time` رشتهٔ `H:i` به وقتِ محلیِ شعبهٔ منبع
است (`ResourceBookingSlotService::dayStart()` این را از `address->getTimezone()` می‌گیرد).
- **مصرف‌کنندهٔ cross-repo:** این قرارداد را `nobat724_front/services/response.js` مصرف می‌کند.
هر تغییر در نام فیلدها بعد از این، در build سایت خطا **نمی‌دهد** — پس شکل پاسخ را قبل از
merge نهایی کن.
@@ -0,0 +1,510 @@
# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده
## پروژه
`clinicpro` — بک‌اند Symfony و پنل ادمین React.
تک‌ریپو است. `nobat724_front` فقط سه فراخوانی داشبورد نماینده دارد
(`services/response.js` خطوط ۲۰۲ تا ۲۱۱) و به هیچ‌کدام از اندپوینت‌های این تسک دست نمی‌زند.
## زمینه
نماینده امروز می‌تواند پزشک و کلینیک بسازد. هنگام ساخت،
`representation_id` روی رکورد ست می‌شود:
```php
// src/Representation/Controller/RepresentationActionController.php:299
$rep = $this->representationRepo->findByUser($user);
if ($rep !== null) {
$doctor->setRepresentationId($rep->getId());
}
```
ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد.
نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس.
عملاً onboarding نیمه‌کاره می‌ماند و پزشک تازه‌ساخته روی سایت عمومی
یک رکورد خالی است.
## مشکل / هدف
نماینده باید روی هر پزشک و کلینیکی که `representation_id` آن به او اشاره دارد،
فیلدهای **محتوایی و ظاهری** را ویرایش کند — و فقط همان‌ها.
مجوز **دائمی** است و به `representation_id` گره می‌خورد.
هیچ فیلد جدید، هیچ migration، هیچ state تازه‌ای لازم نیست.
## معیار پذیرش
- ✅ موفق: نماینده‌ای که پزشک X را ساخته،
`PATCH /api/v1/doctor/{X.uuid}` با بدنهٔ `{"info": "متن جدید", "images": [...]}` می‌فرستد
`200` و رکورد ذخیره می‌شود.
همین برای `PATCH /api/v1/clinic/{uuid}` با `{"clinic_logo": "...", "info": "..."}`.
- ✅ موفق: `GET /api/v1/doctor/{X.uuid}` با توکن همان نماینده → `can_edit: true`.
همان درخواست با توکن نمایندهٔ دیگر → `can_edit: false`.
بدون توکن → `can_edit: false` و بقیهٔ پاسخ مثل قبل.
- ❌ خطا: نمایندهٔ **دیگری** (که این پزشک را نساخته) همان PATCH را بفرستد
`403` با `ERR_AUTH_006`.
- ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد
(`medical_system_code` یا `active` برای پزشک، `doctors` برای کلینیک)
`403` با نام همان فیلد در `errors[0].field`. **هیچ چیزی ذخیره نمی‌شود.**
- ⚠️ مرزی: کاربری با `ROLE_REPRESENTATION` که ردیف `Representation` ندارد
`403`، نه `500`.
- ⚠️ مرزی: پزشکی که `representation_id` آن `null` است
→ هیچ نماینده‌ای اجازه ندارد؛ `403`.
- ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین **دقیقاً مثل قبل** رفتار می‌کنند —
whitelist روی آن‌ها اعمال نمی‌شود و همچنان می‌توانند `medical_system_code`
و `doctors` را عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمی‌کند.
- ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف `AppLog` می‌سازد.
PATCH مالک یا ادمین هیچ ردیفی نمی‌سازد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Representation/Security/RepresentationEditPolicy.php` | **جدید** — مالکیت و whitelist |
| `src/Doctor/Controller/DoctorController.php` | `update`، `show`، سه اکشن آدرس پزشک |
| `src/Clinic/Controller/ClinicController.php` | `update`، `show`، سه اکشن آدرس کلینیک |
| `src/Representation/Repository/RepresentationRepository.php` | `findByUser()` موجود است |
| `src/Shared/Logging/AppLog.php` | entity لاگ موجود |
| `assets/admin/pages/DoctorDetailPage.tsx` | `isReadOnly` خط ۱۱۲۵ |
| `assets/admin/pages/ClinicDetailPage.tsx` | `isReadOnly` خط ۳۶۴ |
| `docs/api/doctor.md` · `docs/api/clinic.md` | سند اندپوینت‌ها |
## وضعیت فعلی
### دروازهٔ پزشک — فقط خود پزشک یا ادمین
```php
// src/Doctor/Controller/DoctorController.php:340
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
$this->hydrateDoctor($doctor, $data);
$this->doctorRepo->save($doctor);
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
}
```
### دروازهٔ کلینیک — از checker موجود رد می‌شود
```php
// src/Clinic/Controller/ClinicController.php:223
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
$clinic = $this->clinicRepo->findByUuid($uuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (($err = $this->validateGallerySize($data)) !== null) {
return $err;
}
$this->hydrateClinic($clinic, $data);
$this->clinicRepo->save($clinic);
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
}
```
`ClinicDoctorPermissionChecker::can()` نقش نماینده را نمی‌شناسد:
```php
// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
return false;
}
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
}
```
### آدرس‌ها — دروازهٔ جدا
```php
// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```
```php
// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱)
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```
```php
// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403);
}
```
### UI — عمداً قفل است
```tsx
// assets/admin/pages/DoctorDetailPage.tsx:1124
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
```
```tsx
// assets/admin/pages/ClinicDetailPage.tsx:363
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
```
### GET جزئیات — هنوز کاربر جاری را نمی‌گیرد
```php
// src/Doctor/Controller/DoctorController.php:150
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
```
```php
// src/Clinic/Controller/ClinicController.php:159
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
```
هر دو عمومی‌اند و `#[CurrentUser]` ندارند.
## وظایف
### ۱. کلاس سیاست — `RepresentationEditPolicy`
فایل جدید: `src/Representation/Security/RepresentationEditPolicy.php`
تنها تصمیم‌گیرندهٔ «این نماینده روی این رکورد چه اجازه‌ای دارد».
هیچ controllerی نباید `representation_id` را دستی مقایسه کند.
```php
<?php
namespace App\Representation\Security;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Representation\Repository\RepresentationRepository;
/**
* «نمایندهٔ ثبت‌کننده روی پروفایلی که خودش ساخته چه اجازه‌ای دارد؟»
*
* مجوز دائمی است و تنها به representation_id گره می‌خورد — نماینده تا وقتی
* رکورد به او اشاره می‌کند مالکِ محتوای آن است. عمداً فقط فیلدهای محتوایی
* باز است: عضویت پزشکان در کلینیک و کد نظام پزشکی و فعال/غیرفعال بودن،
* تصمیم‌های صاحبِ رکوردند نه فروشنده‌ای که او را ثبت کرده.
*/
class RepresentationEditPolicy
{
/** فیلدهایی که نماینده روی پروفایل پزشک می‌تواند بفرستد. */
public const DOCTOR_FIELDS = [
'title', 'gender', 'degree', 'info', 'detail',
'mobile_number', 'activity_time',
'images', 'image_data', 'social_media',
'specialties', 'doctor_services', 'expertise',
'states', 'cities',
];
/** فیلدهایی که نماینده روی پروفایل کلینیک می‌تواند بفرستد. */
public const CLINIC_FIELDS = [
'name', 'info', 'address', 'telephone',
'working_days', '24_7', 'latitude', 'longitude',
'practice_domain_uuid', 'state', 'city',
'social_media', 'image_clinic', 'clinic_logo',
];
public function __construct(
private readonly RepresentationRepository $repRepo,
) {}
public function ownsDoctor(User $user, Doctor $doctor): bool
{
return $this->matches($user, $doctor->getRepresentationId());
}
public function ownsClinic(User $user, Clinic $clinic): bool
{
return $this->matches($user, $clinic->getRepresentationId());
}
/**
* اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند.
*
* @param list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
*/
public function firstForbiddenField(array $data, array $allowed): ?string
{
foreach (array_keys($data) as $key) {
if (!in_array($key, $allowed, true)) {
return (string) $key;
}
}
return null;
}
private function matches(User $user, ?int $representationId): bool
{
if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) {
return false;
}
$rep = $this->repRepo->findByUser($user);
return $rep !== null && $rep->getId() === $representationId;
}
}
```
نکته‌های اجباری:
- `matches()` وقتی `ROLE_REPRESENTATION` نیست، **بدون کوئری** برمی‌گردد.
این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم
به `false` می‌بندد، نه به exception.
- کلاس هیچ HTTP نمی‌شناسد. پاسخ ۴۰۳ کارِ controller است.
**نحوه تست:** unit test خالص با entityهای ساختگی —
`tests/Representation/RepresentationEditPolicyTest.php`.
سناریوها: مالکِ درست `true`؛ نمایندهٔ دیگر `false`؛ `representation_id === null``false`؛
کاربر بدون `ROLE_REPRESENTATION``false`؛ `firstForbiddenField` روی
`['info' => 'x', 'doctors' => []]` با `CLINIC_FIELDS` باید `'doctors'` بدهد و
روی `['info' => 'x']` باید `null` بدهد.
### ۲. لاگ‌کردن ویرایشِ نماینده
سرویس کوچک کنار سیاست: `src/Representation/Security/RepresentationEditLogger.php`
از entity موجود `App\Shared\Logging\AppLog` استفاده کن — هیچ جدول جدیدی نساز.
سازندهٔ آن `(level, message, context, channel, path)` می‌گیرد.
```php
$this->em->persist(new AppLog(
'info',
sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid),
json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE),
'representation_edit',
$request->getPathInfo(),
));
```
فقط وقتی لاگ بنویس که ویرایش‌کننده **نماینده** باشد.
مالک و ادمین هیچ ردیفی نمی‌سازند — وگرنه `/admin/logs` پر از نویز می‌شود.
**نحوه تست:** بعد از یک PATCH موفقِ نماینده،
`SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'` باید یکی زیاد شده باشد.
بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند.
### ۳. باز کردن `PATCH /api/v1/doctor/{uuid}`
در `DoctorController::update` شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار.
ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate.
```php
$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
$isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor);
if (!$isOwnerOrAdmin && !$isRepOwner) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if ($isRepOwner) {
$bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS);
if ($bad !== null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
}
}
```
whitelist **فقط** روی `$isRepOwner` اجرا می‌شود. مسیر مالک و ادمین دست‌نخورده می‌ماند.
**نحوه تست:** تست فانکشنال با `ApiTestCase`
`tests/Representation/RepresentationProfileEditTest.php`.
یک نماینده و پزشکش بساز (از `POST /api/v1/representation/doctor` استفاده کن، نه fixture دستی)،
بعد PATCH با `info` بزن و `200` بگیر، بعد PATCH با `medical_system_code` بزن و `403` بگیر
و مطمئن شو مقدار قبلی در دیتابیس عوض نشده.
### ۴. باز کردن `PATCH /api/v1/clinic/{uuid}`
همان الگو. ولی اینجا `permChecker` جلوی راه است.
**آن را تغییر نده.** `ClinicDoctorPermissionChecker` دربارهٔ عضویت پزشک در کلینیک است
و نماینده اصلاً پزشکِ عضو نیست؛ اضافه‌کردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا می‌کند.
به‌جایش در controller کنارش بگذار:
```php
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);
if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```
حواست به خط ۲۳۰ باشد:
```php
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
```
این پیش‌چکِ منشی است و پیش از واکشی رکورد اجرا می‌شود.
بررسی کن که برای کاربرِ `ROLE_REPRESENTATION` (که منشی نیست) throw نکند.
اگر throw می‌کند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن.
whitelist با `RepresentationEditPolicy::CLINIC_FIELDS`.
**نحوه تست:** نماینده و کلینیکش را با `POST /api/v1/representation/clinic` بساز.
`PATCH` با `{"clinic_logo": "https://x/y.png", "info": "..."}``200`.
`PATCH` با `{"doctors": [1]}``403` و `errors[0].field === 'doctors'`.
یک نمایندهٔ دوم بساز و همان PATCH را بزن → `403`.
### ۵. آدرس‌ها — شش اکشن
همان سیاست را در این‌ها هم صدا بزن:
- `POST /api/v1/clinic-pro/doctor-address` — نماینده باید `doctor_uuid` بفرستد،
دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود.
- `PATCH /api/v1/clinic-pro/doctor-address/{id}`
- `DELETE /api/v1/clinic-pro/doctor-address/{id}`
- `POST /api/v1/clinic/{clinicUuid}/address`
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
روی آدرس‌ها whitelist لازم نیست — کل رکورد آدرس محتوایی است.
شرط `TYPE_PERSONAL` در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید
آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند.
**نحوه تست:** نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه `200`.
نمایندهٔ دوم روی همان آدرس `403` بگیرد.
### ۶. فلگ `can_edit` در پاسخ GET جزئیات
هر دو `show` را طوری عوض کن که کاربر جاری را اختیاری بگیرند:
```php
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
```
و در آرایهٔ خروجی:
```php
'can_edit' => $user !== null && (
$doctor->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN')
|| $this->editPolicy->ownsDoctor($user, $doctor)
),
```
برای کلینیک همان با `permChecker->can(...) || editPolicy->ownsClinic(...)`.
هر دو اندپوینت عمومی‌اند. بدون توکن باید `can_edit: false` بدهند و
هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همین‌ها را مصرف می‌کند.
**نحوه تست:** سه بار `GET /api/v1/doctor/{uuid}` — بدون توکن، با توکن نمایندهٔ مالک،
با توکن نمایندهٔ دیگر. مقادیر `false` و `true` و `false`.
### ۷. باز کردن UI پنل
در `DoctorDetailPage.tsx` و `ClinicDetailPage.tsx` این خط را بردار:
```tsx
const isReadOnly = primaryRole === 'representation';
```
و جایش از پاسخ سرور بخوان:
```tsx
const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit;
```
هیچ منطق مجوزی را در فرانت بازنویسی نکن. `can_edit` تنها منبع حقیقت است.
فیلدهای بیرون از whitelist باید برای نماینده در فرم **مخفی یا disabled** باشند،
نه اینکه ارسال شوند و ۴۰۳ بگیرند:
- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال
- کلینیک: مدیریت پزشکان کلینیک
نماینده همچنان می‌تواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش:
`POST /api/v1/representation/doctors/{uuid}/status`.
اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به `PATCH`.
تایپ‌ها را در `assets/admin/types/index.ts` به‌روز کن: `can_edit?: boolean`.
**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` سبز،
`ddev exec yarn dev` بدون خطا، و ورود دستی با یک کاربر نماینده در
`https://clinic-pro.ddev.site/admin/doctors/<uuid>` — دکمهٔ ویرایش و آپلود لگو دیده شود
و کد نظام پزشکی دیده نشود.
### ۸. مستندات
- `docs/api/doctor.md``PATCH /api/v1/doctor/{uuid}`: نقش نماینده، فهرست فیلدهای مجاز،
و ۴۰۳ فیلد ممنوع. `GET`: فیلد `can_edit`.
- `docs/api/clinic.md` — همان برای کلینیک.
- اندپوینت‌های آدرس در هر دو سند.
- JSON نمونه باید **خروجی اجرای واقعی** باشد، نه دست‌ساز.
## نکات مهم
- **الگو: Policy Object.** یک کلاس، یک سؤال: «این نماینده چه اجازه‌ای دارد».
دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار می‌شود؛ اگر inline بنویسی،
فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا می‌ماند.
`ClinicDoctorPermissionChecker` را گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است،
و پزشکِ نماینده اصلاً عضو نیست.
- **این تسک هیچ دسترسی موجودی را تنگ نمی‌کند.** فقط باز می‌کند.
اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده.
- **`active` عمداً بیرون است.** نماینده اندپوینت اختصاصی دارد:
`POST /api/v1/representation/doctors/{uuid}/status` (خط ۶۳۲ همان controller).
دو مسیر برای یک کار نساز.
- **اندپوینت‌های آپلود دست‌نخورده می‌مانند.**
`/file/upload/clinic_pro/doctor/field_image` و `.../clinic/field_clinic_logo`
الان هم برای هر کاربر لاگین‌شده بازند و فقط URL برمی‌گردانند؛
دروازهٔ واقعی همان PATCH است که URL را ذخیره می‌کند.
- **کلید ممنوع = ۴۰۳، نه حذف بی‌صدا.** حذف بی‌صدا یعنی نماینده فکر می‌کند ذخیره شده
و تا مدت‌ها کسی نمی‌فهمد. `$this->error(..., 403, $fieldName)` امضای فیلد را هم می‌گیرد.
- **`مالکیت` را از `representation_id` بخوان، نه از نقش.**
هر کاربری می‌تواند `ROLE_REPRESENTATION` داشته باشد؛ آنچه مهم است اینکه
رکورد `Representation` او همان id باشد که روی پزشک/کلینیک نشسته.
- **قرارداد API عوض نمی‌شود، فقط گسترده می‌شود.** `can_edit` فیلد جدید و اختیاری است.
ولی `GET /api/v1/doctor/{uuid}` و `GET /api/v1/clinic/{uuid}` را
`nobat724_front` هم مصرف می‌کند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را
دستی باز کن و مطمئن شو چیزی نشکسته. build آن‌ها خطا نمی‌دهد.
- تست‌ها زیر `tests/Representation/` بروند. دیتابیس تست هرگز reset نمی‌شود،
پس دادهٔ هر تست را با مقدار یکتا بساز (`uniqid()`) تا اجرای دوم هم سبز بماند.
@@ -0,0 +1,368 @@
# نوبت‌دهی منبع‌محور در صفحهٔ نوبت‌ها — تب هر منبع + رزرو سرویسی + حذف تایم‌لاین منابع
> **وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲).** کامیت‌ها: `3e3a2482` (تب منابع + فیلتر)،
> `fd27ceef` (مودال رزرو + حذف تایم‌لاین).
>
> ## این پرامپت در اجرا سه‌بار غلط از آب درآمد — چیزی که واقعاً شد:
>
> **۱. «نوبت بدون پزشک» پیاده شد و بعد کاملاً برگردانده شد.** `doctor` تهی‌پذیر شد و
> migration اجرا شد، ولی وسط کار معلوم شد مدل مجوز منشی روی سه‌تایی
> `(منشی، کلینیک، پزشک)` بنا شده و `DoctorSecretary.doctor` تهی‌پذیر نیست — یعنی برای
> نوبتِ بدون پزشک **هیچ ردیف مجوزی وجود ندارد**. تصمیم کاربر: منبع پزشکِ مسئول داشته
> باشد. migration رول‌بک و کد `git checkout` شد.
>
> **۲. «۷۳ فراخوانی getDoctor()» بیش‌برآورد بود.** بیشترشان روی entityهای دیگرند
> (`WeeklySchedule`، `Holiday`، `Rate`، `DoctorAddress`). عدد واقعی روی `Appointment`
> حدود ۲۵ بود، و diff سطح ۸ دقیقاً **۳۳ نقطهٔ جدید در ۱۶ فایل** داد.
>
> **۳. مهم‌ترین: کل موتور از قبل ساخته شده بود و این پرامپت از وجودش بی‌خبر بود.**
> `POST /api/v1/appointment-availability` (با `assignment` per اسلات)، `appointment-hold`،
> `appointment-confirm`، هوک `useResourceBooking.ts`، و حتی یک صفحهٔ کامل
> `ResourceBookingPage.tsx` روی `/admin/resource-booking`. `confirm` هم از قبل
> `doctor_uuid` می‌گیرد. پس **هیچ تغییر بک‌اندی برای رزرو لازم نبود** و تنها افزودنی
> بک‌اند، فیلتر `resource_uuid` روی فهرست نوبت‌ها شد.
>
> ## دو تلهٔ ابزاری که باید بدانی
>
> - **`phpstan` این پروژه تهی‌پذیری را چک نمی‌کند.** بررسی «صدا زدن متد روی تهی» سطح ۸
> است و `phpstan.neon` روی سطح ۵. با یک خطای عمدی تست شد: `[OK] No errors`. برای این
> جنس تغییر، گیت واقعی PHPUnit است، نه phpstan.
> - **`doctrine:migrations:diff` تغییر nullable را ندید** و به‌جایش یک migration بی‌ربط
> `messenger_messages` ساخت. migration دستی نوشته شد.
>
> ## معماری‌ای که ماند
>
> - رزرو منبع از `hold → confirm` می‌رود، نه `POST /api/v1/appointment`. دلیلش حیاتی است:
> فقط `HoldService` رکورد `resource_occupancy_buckets` می‌نویسد و قید یکتای
> `uniq_bucket_resource_seat` تداخل را غیرممکن می‌کند. `book()` هیچ occupancy نمی‌نویسد،
> پس رزرو منبع از آن مسیر بی‌گارد است.
> - موتور خدمت‌محور جواب می‌دهد؛ مودال نتیجه را به اسلات‌هایی تنگ می‌کند که `assignment`شان
> همین منبع را دارد و آن نقش را به منبع قفل می‌کند.
>
> بقیهٔ این فایل متن اولیهٔ پرامپت است و برای تاریخچه نگه داشته شده — **به‌عنوان دستورالعمل
> اجرا معتبر نیست.**
---
## زمینه
صفحهٔ `/admin/appointments` امروز کاملاً پزشک‌محور است: تب‌ها فقط پزشک‌اند
(`AppointmentsPage.tsx:841`)، و منابع فقط یک نوار **فقط‌خواندنی** زیر زمان‌بندی دارند
(`AppointmentsPage.tsx:892-903`) که هیچ اقدامی روی آن ممکن نیست.
در مدل Resource-First، منبع واحد ظرفیت است: «لیزر CO2» و «لیزر NdYAG» سرویس‌های خودشان
(`ResourceServiceOffering`)، تقویم خودشان (`ResourceCalendar`) و استثناهای خودشان را دارند.
ولی کاربر نمی‌تواند برای آن‌ها نوبت ثبت کند، چون کل مسیر رزرو از پزشک عبور می‌کند.
## مشکل / هدف
**هدف:** هر منبع مثل پزشک تب خودش را داشته باشد؛ «افزودن نوبت» روی تب یک منبع، مودال
نوبت‌دهی **سرویسی** را برای همان منبع باز کند (لیست سرویس‌های همان منبع → انتخاب →
زمان خالی → بیمار → ثبت)؛ و نوار «منابع» زیر زمان‌بندی حذف شود.
**سه مانع واقعی در کد امروز:**
۱. **نوبت بدون پزشک ممکن نیست.** `Appointment::$doctor` با `nullable: false` تعریف شده
(`Appointment.php:100-101`) و سازندهٔ entity هم `Doctor` می‌گیرد (`Appointment.php:256`).
`book()` بدون `doctor_uuid` خطای ۴۲۲ می‌دهد (`AppointmentController.php:484`) و منبع فقط
وقتی پزشک پیدا می‌کند که خودش پزشک باشد (`AppointmentController.php:471-473`). یعنی
دستگاهِ بدون پزشک اصلاً قابل رزرو نیست.
۲. **اسلات سرویسی فقط پزشک‌محور است.** `GET /api/v1/appointment-service-slots` پزشک
می‌خواهد و شرط می‌کند `booking_mode` همان پزشک `service` باشد
(`AppointmentController.php:193-209`). منبع `WeeklySchedule` ندارد.
۳. تایم‌لاین منابع باید حذف شود.
**تصمیم گرفته‌شده (توسط کاربر):** مسیر «نوبت بدون پزشک» — `doctor` تهی‌پذیر شود.
### چرا این تصمیم آن‌قدر که به‌نظر می‌رسد پرریسک نیست (شواهد از کد)
- `Appointment::$resource` **از قبل وجود دارد** و تهی‌پذیر است (`Appointment.php:174-176`).
- تداخل منابع **از قبل در سطح دیتابیس** تضمین شده، نه در کد: `OccupancyBucket` با
`UniqueConstraint('uniq_bucket_resource_seat', ['resource_id','bucket_at','seat'])`
(`OccupancyBucket.php:22`). پس `activeSlotKey` مسئول تداخل **منبع** نیست.
- `activeSlotKey` فقط دوباره‌رزروی **همان پزشک** را می‌گیرد:
`sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)` (`Appointment.php:283`).
وقتی پزشکی وجود ندارد، «دوباره‌رزروی پزشک» بی‌معناست و `null` بودنِ کلید معنای درستی
است — نه یک حفرهٔ ایمنی.
**ریسک واقعی و باقی‌مانده:** ۷۳ فراخوانی `getDoctor()` در ۲۱+ فایل `src/` که همه امروز
`Doctor` غیرتهی فرض می‌کنند. این بخش سنگین کار است و باید تک‌تک بررسی شود.
## معیار پذیرش
-**موفق:** با توکن مالک کلینیک، `POST /api/v1/appointment` با بدنهٔ
`{resource_uuid, service_item_uuids[], slot_start, patient_national_code, patient_gender}`
و **بدون** `doctor_uuid`، برای منبعِ دستگاهی (`subject_kind = null`) → `200` و نوبت ذخیره
می‌شود با `doctor_id = NULL` و `resource_id` پرشده. در UI: تب «لیزر CO2» → «افزودن نوبت» →
انتخاب سرویس → انتخاب زمان → ثبت → نوبت در فهرست همان تب دیده می‌شود.
-**موفق:** `GET /api/v1/appointment-service-slots?resource_uuid=…&date=…&service_item_uuids[]=…`
`200` با همان شکل پاسخِ حالت پزشک (`start_times[]`, `total_duration_minutes`).
-**خطا:** رزرو منبعی که آن سرویس را ارائه نمی‌دهد → `422` با پیام
«این منبع این سرویس را ارائه نمی‌دهد» و `field = resource_uuid` (این بررسی از قبل در
`AppointmentController.php:533-540` هست و باید در مسیر بدون‌پزشک هم اجرا شود).
-**خطا:** رزرو منبعِ محیط دیگر → `422` «منبع یافت نشد» با `field = resource_uuid`.
-**خطا:** نه `doctor_uuid` و نه `resource_uuid``422` با envelope خطا.
- ⚠️ **مرزی:** دو رزروِ هم‌زمان روی یک منبع با ظرفیت ۱ در یک بازه → دومی باید با
`409` رد شود (از قید یکتای `uniq_bucket_resource_seat`، نه از بررسی در کد).
- ⚠️ **مرزی:** منبعی که آن روز شیفت ندارد → `start_times` خالی و پیام «زمان خالی کافی
نیست»، نه خطای ۵۰۰.
- ⚠️ **مرزی:** نوبت‌های قدیمیِ دارای پزشک باید بدون تغییر کار کنند (هم API، هم پنل، هم
سایت عمومی) — `doctor` تهی‌پذیر شده، حذف نشده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Entity/Appointment.php` | `doctor` تهی‌پذیر، `activeSlotKey`، `toArray()` |
| `src/Appointment/Controller/AppointmentController.php` | `book()` و `serviceSlots()` |
| `src/Appointment/Booking/Entity/OccupancyBucket.php` | تضمین یکتاییِ اشغال منبع (فقط مرجع — تغییر نمی‌کند) |
| `src/Resource/Entity/ResourceServiceOffering.php` | سرویس‌ها و مدت مؤثرِ هر منبع |
| `src/Resource/Entity/ResourceCalendar.php` | شیفت هفتگی منبع (مبنای اسلات) |
| `migrations/` | migration تهی‌پذیر کردن `appointments.doctor_id` |
| `assets/admin/pages/AppointmentsPage.tsx` | تب‌ها، مودال ثبت، حذف بخش منابع |
| `assets/admin/components/appointments/DoctorTabs.tsx` | تب‌ها (باید عمومی شود) |
| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | انتخاب سرویس/زمان (باید منبع را هم بپذیرد) |
| `assets/admin/components/appointments/ResourceTimeline.tsx` + `.test.tsx` | **حذف** |
| `assets/admin/hooks/useResourceTimeline.ts` | **حذف** اگر مصرف‌کنندهٔ دیگری ندارد |
| `docs/api/appointment-booking.md`, `docs/api/appointment.md`, `docs/api/resource.md` | مستندات |
## وضعیت فعلی
`Appointment.php` — پزشک اجباری و کلید یکتا بر پایهٔ پزشک:
```php
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private Doctor $doctor;
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
private function refreshActiveSlotKey(): void
{
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
public function getDoctor(): Doctor { return $this->doctor; }
```
`AppointmentController::book()` — بدون پزشک رد می‌شود:
```php
if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422);
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
```
`AppointmentController::serviceSlots()` — پزشک‌محور و مقیّد به `booking_mode` پزشک:
```php
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
}
```
`AppointmentsPage.tsx` — تب فقط پزشک، و بخش منابع که باید حذف شود:
```tsx
{showDoctorTabs && (
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و
دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */}
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
<ResourceTimeline
lanes={resourceDay?.resources ?? []}
dayStart={resourceDay?.date ?? 0}
loading={resourceTimelineLoading}
error={resourceTimelineError}
/>
</div>
```
## وظایف
> ترتیب اجباری است: بک‌اند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی
> خرابه ساخته می‌شوند.
### ۱. تهی‌پذیر کردن `Appointment::$doctor`
```php
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
private ?Doctor $doctor = null;
public function __construct(?Doctor $doctor, User $user, int $slotStart, int $slotEnd)
public function getDoctor(): ?Doctor { return $this->doctor; }
/**
* کلید یکتای اسلات فقط دوباره‌رزروی «همان پزشک» را می‌گیرد. نوبتِ منبع‌محورِ بدون
* پزشک چنین تداخلی ندارد؛ تداخلِ خودِ منبع را قید یکتای
* `uniq_bucket_resource_seat` روی `resource_occupancy_buckets` می‌گیرد.
*/
private function refreshActiveSlotKey(): void
{
$this->activeSlotKey = $this->doctor !== null
&& !$this->isReserve
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
```
سپس **همهٔ ۷۳ فراخوانی `getDoctor()`** را بررسی کن:
```bash
ddev exec grep -rn "getDoctor()" src/ | wc -l # باید ۷۳ باشد
ddev exec php vendor/bin/phpstan analyse # سطح ۵ — تهی‌پذیرهای بررسی‌نشده را می‌گیرد
```
قاعده: هر جا پزشک واقعاً لازم است (تعرفه، برنامهٔ هفتگی، پیامک پزشک) → `null` را با
خطای معنادار رد کن؛ هر جا فقط نمایشی است (`toArray()` خط ۴۹۹ و ۵۰۳) → مقدار تهی برگردان
نه استثنا.
**نحوه تست:** `ddev exec php bin/phpunit tests/Appointment/` باید کامل سبز بماند (رگرسیون
مسیر پزشک‌دار). به‌علاوه `ddev exec php vendor/bin/phpstan analyse` بدون خطای تهی‌پذیری.
### ۲. Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
**نحوه تست:** بعد از migrate، `DESCRIBE appointments;` باید `doctor_id` را `YES` (nullable)
نشان دهد، و ردیف‌های موجود دست‌نخورده بمانند:
```bash
ddev exec mysql -e "SELECT COUNT(*) FROM appointments WHERE doctor_id IS NULL;" # قبل از فیچر: 0
```
### ۳. اسلات سرویسیِ منبع‌محور
`GET /api/v1/appointment-service-slots` را طوری توسعه بده که **یا** `doctor_uuid` بگیرد
**یا** `resource_uuid` — با همان شکل پاسخ.
**الگو: Strategy.** یک interface مثل `ServiceSlotSource` با دو پیاده‌سازی
`DoctorServiceSlotSource` و `ResourceServiceSlotSource`. دلیل انتخاب (guidelines §۵):
همین حالا **دو** پیاده‌سازی واقعی وجود دارد — پزشک از `WeeklySchedule` و اسلات‌های پزشک
می‌آید، منبع از `ResourceCalendar` + `ResourceException` + `resource_blocks` + اشغال.
بدون Strategy، این می‌شد یک `if` روی نوعِ شناسه داخل کنترلر که با هر منبعِ جدید رشد می‌کند.
نکات محاسبه برای منبع:
- مدت هر سرویس از `ResourceServiceOffering` همان منبع (مدت مؤثر)، نه از پیش‌فرض سرویس.
- شرط `booking_mode === MODE_SERVICE` برای منبع **اعمال نشود** — آن قید مالِ برنامهٔ
هفتگی پزشک است و منبع اصلاً `WeeklySchedule` ندارد.
- بازه‌های اشغال از همان مسیری بیاید که تایم‌لاین منبع می‌خواند
(`ResourceOccupancyRepository`)، نه یک کوئری موازیِ جدید.
**نحوه تست:**
```bash
TOKEN=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"0912000201","password":"QaTest@1234"}' | jq -r .access_token)
curl -sk "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?resource_uuid=<UUID>&date=$(date +%F)&management=1&service_item_uuids[]=<SERVICE_UUID>" \
-H "Authorization: Bearer $TOKEN" | jq
# انتظار: success:true و start_times آرایه‌ای از {start,end,start_time}
```
### ۴. رزرو بدون پزشک در `book()`
- اگر `resource_uuid` آمده و منبع پزشک‌پشت ندارد، دیگر به `doctor_uuid` اصرار نکن.
- شرط خطا به این تغییر کند: «حداقل یکی از `doctor_uuid` یا `resource_uuid` لازم است».
- بررسی «این منبع این سرویس را ارائه نمی‌دهد» (`AppointmentController.php:533-540`) باید
در مسیر بدون‌پزشک هم اجرا شود — امروز داخل بلوکی است که به `$duration` وابسته است.
- بررسی مالکیت محیط منبع (`EntityContext::forBooking`) وقتی پزشک نداریم باید از **محیط
کاربر جاری** بیاید، نه از پزشک.
**نحوه تست:** رزرو واقعی روی یک منبع دستگاهی (بدون `doctor_uuid`) → ۲۰۰؛ سپس همان بازه
دوباره → ۴۰۹. هر دو با `curl` و توکن بالا. و
`ddev exec mysql -e "SELECT doctor_id, resource_id FROM appointments ORDER BY id DESC LIMIT 1;"`.
### ۵. تب منابع در کنار تب پزشکان
`DoctorTabs.tsx` امروز فقط `{uuid, name}` می‌گیرد و رفتارش کاملاً عمومی است — **همان را
عمومی کن** (مثلاً `EntityTabs`) به‌جای ساختن کامپوننت دوم؛ اسم فعلی‌اش تنها چیزِ پزشکیِ
آن است (guidelines §۵: اول بگرد، بعد توسعه بده، در آخر بساز).
تب انتخاب‌شده باید در URL بنشیند (`useUrlState`) وگرنه «بازگشت» نما را می‌پراند —
همان قاعده‌ای که در `CLAUDE.md` برای وضعیت لیست آمده. پیشنهاد: `?tab=doctor:<uuid>` و
`?tab=resource:<uuid>` تا یک کلید هر دو نوع را بگیرد.
**نحوه تست:** `npx vitest run assets/admin/pages/AppointmentsPage.test.tsx` + تست جدید:
کلیک روی تب یک منبع → فهرست همان منبع؛ رفرش صفحه → همان تب فعال بماند.
### ۶. مودال ثبت نوبت برای منبع
`ServiceSlotPicker` باید به‌جای `doctorUuid` اجباری، یکی از این دو را بگیرد. سرویس‌ها
برای منبع از `GET /api/v1/resource/{uuid}/services` می‌آید (نه از
`appointment-booking-services/{doctorUuid}`).
ترتیب مودال دقیقاً مثل عکس مرجع و مثل حالت پزشک بماند:
سرویس‌های منبع → سرویس‌های انتخاب‌شده (با مدت قابل ویرایش) → زمان‌های خالی → جستجوی
بیمار → هزینه → ثبت.
**نحوه تست:** `npx vitest run assets/admin/components/appointments/ServiceSlotPicker.test.tsx`
(تست موجود نباید بشکند) + تست جدید برای حالت منبع. سناریوی UI: تب «لیزر CO2» → افزودن
نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود.
### ۷. حذف تایم‌لاین منابع
- بلوک `<h2>منابع</h2> + <ResourceTimeline …>` از `AppointmentsPage.tsx` حذف شود.
- `ResourceTimeline.tsx` و `ResourceTimeline.test.tsx` حذف شوند.
- `useResourceTimeline.ts` **فقط اگر** مصرف‌کنندهٔ دیگری ندارد حذف شود:
```bash
grep -rn "useResourceTimeline" assets/admin/
```
- `GET /api/v1/resources/timeline` در بک‌اند **دست‌نخورده** بماند (اندپوینت خودش
مشکلی ندارد؛ فقط این مصرف‌کننده حذف می‌شود). اگر بعد از حذف هیچ کلاینتی ندارد، در
گزارش پایانی ذکر کن تا کاربر تصمیم بگیرد.
**نحوه تست:** `npx vitest run` کامل سبز؛ و اسکرین‌شات صفحه بعد از
`ddev exec yarn dev` که دیگر بخش «منابع» زیر زمان‌بندی ندارد.
### ۸. مستندات
- `docs/api/appointment-booking.md`: `resource_uuid` بدون `doctor_uuid`، و اینکه
`doctor` در پاسخ می‌تواند `null` باشد.
- `docs/api/appointment.md`: پارامتر `resource_uuid` در `appointment-service-slots` با
JSON واقعیِ اجرا (نه دست‌ساز).
- `docs/api/resource.md`: اشاره به اینکه سرویس‌های منبع مبنای رزرو منبع‌محورند.
## نکات مهم
- **این تغییر cross-repo است.** `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همین
API‌اند و `appointment.doctor` را غیرتهی فرض می‌کنند. تغییر قرارداد در build آن‌ها خطا
**نمی‌دهد** و در رانتایم می‌شکند (guidelines §۳). بعد از وظیفهٔ ۱، مصرف واقعی را در
`nobat724_front/services/response.js` و صفحات نوبت دستی دنبال کن و نتیجه را گزارش بده.
- `activeSlotKey` را برای نوبت بدون پزشک `null` بگذار — این حفره نیست؛ تداخل منبع را
`uniq_bucket_resource_seat` می‌گیرد. اگر وسوسه شدی کلید را `r<id>:<slot>` کنی، اول
بررسی کن که با ظرفیت >۱ منبع نمی‌شکند (منبع سه‌ظرفیتی سه رزرو هم‌زمان دارد).
- Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository،
تزریق با constructor injection.
- خطاها با `AppException(ErrorCodes::ERR_XXX)` و پیام فارسی؛ پاسخ‌ها با
`$this->success()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح؛ رشته‌های UI فارسی و تاریخ‌های نمایشی جلالی.
- اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی `getDoctor()` نیازمند تصمیم محصولی است
(مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن.
@@ -0,0 +1,385 @@
# نوبت‌دهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت
## پروژه
`clinicpro` (backend + پنل ادمین).
**cross-repo:** قرارداد `POST /api/v1/appointment` و `GET /api/v1/appointment-booking-locations/{doctorUuid}` را سایت عمومی مصرف می‌کند
(`nobat724_front/services/response.js``getBookingLocations`, `getServiceSlots`, `postAppointment`).
تغییر قرارداد در build سایت خطا نمی‌دهد؛ باید دستی دنبال شود.
## قواعد غیرقابل‌مذاکره
این سه بند شرط پذیرش‌اند، نه توصیه. کاری که این‌ها را نقض کند «تمام‌شده» نیست حتی اگر تست‌هایش سبز باشد.
**۱. هر UI جدید داخل تم فعلی، در حد یک متخصص UI/UX.** هیچ تم، پالت، فونت یا کتابخانهٔ CSS تازه‌ای
ساخته نمی‌شود. مشخصاً:
- رنگ‌ها فقط از توکن‌های `assets/admin/styles.css` (`var(--primary)`، `var(--surface)`، `var(--border)`،
`var(--text-2)`، …). هیچ hex خامی در کامپوننت جدید.
- کامپوننت از `assets/admin/components/ui/` استفاده شود، نه نسخهٔ دست‌ساز: `DataTable`، `Modal`،
`ConfirmDialog`، `PageHeader` (با `backTo``SearchableSelect`، `StatusBadge`، `Pagination`،
`PersianDateInput`. **`<select>` خام ممنوع** — همیشه `SearchableSelect`.
- سه حالت نمایش باید سالم باشند: دارک‌مود (`[data-theme="dark"]`)، حالت فشرده
(`[data-density="compact"]`) و موبایل ۳۹۰px بدون اسکرول افقی.
- دو تلهٔ شناخته‌شدهٔ همین CSS: `.card` **padding ندارد** (برای فاصله `card-pad` اضافه کن) و
`.field` خودش جعبهٔ ورودی است (برچسبِ بالای فیلد با `.field-block` می‌آید، نه داخل `.field`).
- RTL و متن فارسی؛ تاریخ‌ها شمسی با `formatDate()`.
- صفحهٔ زیرمجموعه بدون دکمهٔ بازگشت پذیرفته نیست: `PageHeader backTo=…` یا `<BackButton fallback=…/>`.
- وضعیت لیست‌ها (جستجو، فیلتر، صفحه) در query string با `hooks/useUrlState.ts`، نه در `useState`.
**۲. هر چیزِ اضافه حذف می‌شود.** فقط منبع، سرویس، گزینهٔ سرویس و دسته‌بندی می‌ماند. وظیفهٔ ۸ فهرست
حذف را دارد؛ ولی قاعده کلی‌تر است: در همین کار هم صفحه، فیلد، endpoint یا گزینه‌ای که سند
نخواسته اضافه نکن.
**۳. ساختار ساده.** بدون abstraction برای آینده:
- جدول جدید فقط وقتی هیچ جدول موجودی — حتی با یک ستون تازه — کافی نباشد؛ دلیلش نوشته شود.
(به همین دلیل «گزینهٔ سرویس» جدول جدید نمی‌گیرد — پایین‌تر.)
- Controller نازک، منطق در Service، کوئری در Repository، وابستگی با constructor injection.
- interface و کلاس پایه فقط وقتی **الان** بیش از یک پیاده‌سازی دارد.
- نام‌گذاری و سبک کد دقیقاً مثل فایل‌های همسایه.
## زمینه
تسک‌های `docs/new_feture/taskes/` لایهٔ منبع را ساخته‌اند — `ResourceType`، `ClinicResource`،
تقویم منبع، استثنا، استخر، مهارت، موتور دسترس‌پذیری و اشغال واقعی. ولی **واحد رزرو هنوز پزشک است**:
نوبت به `Doctor` گره خورده، و «کدام منبع این سرویس را می‌دهد» و «مدت/قیمت این سرویس برای این منبع»
هیچ‌جا داده نیست. سند مالک محصول می‌گوید مدل باید بر پایهٔ منبع باشد، و هر چیزی خارج از
منبع/سرویس/گزینه از محصول حذف شود.
## مشکل / هدف
سه شکاف واقعی نسبت به سند:
1. **رابطهٔ منبع↔سرویس وجود ندارد.** انتخاب منبع فقط با `type + skill` انجام می‌شود، پس
«دستگاه لیزر ۱ این سرویس را می‌دهد ولی دستگاه ۲ نه» قابل بیان نیست.
2. **مدت و قیمت بُعد منبع ندارند.** override موجود per **شعبه** است؛ «دکتر احمدی ۳۰ دقیقه /
دکتر رضایی ۴۵ دقیقه برای همان گزینه» نمایش‌دادنی نیست.
3. **نوبت منبع را نگه نمی‌دارد.** `Appointment.doctor` غیرتهی است و رزرو بدون پزشک ممکن نیست،
پس نوبتِ «دستگاه لیزر ۲» در مدل جا ندارد.
هدف: منبع واحد رزرو شود، مدت و قیمت با زنجیرهٔ **منبع+گزینه → منبع+سرویس → پیش‌فرض** حل شود،
و نوبت منبع و snapshot را نگه دارد.
> **گزینهٔ سرویس در این کدبیس جدول جدید نمی‌خواهد.** «گزینه» همان `ServiceItem` عضو یک
> `ItemGroup` است (`select_min`/`select_max` از قبل هست) و «سرویس» همان آیتم والد. ساختن
> جدول سوم `service_options` یعنی دو منبع حقیقت برای یک چیز، و همهٔ مسیرهای امروز
> (`appointment-booking-services`، `appointment-service-slots`، `PriceListItem`، `Tariff`)
> باید دوباره نوشته شوند. دلیل انتخاب در «نکات مهم» ثبت شده است.
## معیار پذیرش
-**موفق:** برای سرویس «لیزر» و گزینهٔ «پا» دو ردیف منبع ثبت شود (دستگاه ۱: ۲۰ دقیقه/۸ میلیون ریال،
دستگاه ۲: ۱۵ دقیقه/۹٫۵ میلیون ریال). `GET /api/v1/resource/{uuid}/services` هر دو را برگرداند و
`POST /api/v1/appointment` با `resource_uuid` دستگاه ۲ → `201` با
`service_total_minutes: 15` و `price_snapshot.final_rials: 9500000`.
-**خطا:** رزرو با `resource_uuid` منبعی که آن سرویس را ندارد → `422` با
`ERR_VALIDATION_001` و پیام «این منبع این سرویس را ارائه نمی‌دهد»؛ بدون توکن → `401` با envelope خطا.
-**دسته‌بندی:** دستهٔ «تمام بدن» شامل «دست» و «پا» تعریف شود؛ انتخاب هم‌زمان «لیزر تمام بدن» و
«لیزر دست» → `422` با پیام فارسی، و یک دستگاه بتواند هم‌زمان به چند دسته وصل باشد.
- ⚠️ **مرزی:** منبعی که برای گزینه مقدار ندارد ولی برای سرویس دارد → مقدار سطح سرویس استفاده شود؛
منبعی که هیچ‌کدام را ندارد → پیش‌فرض خودِ آیتم؛ و نوبت‌های **قبلاً ثبت‌شده** (بدون `resource_id`)
باید همچنان در لیست‌ها و پنل بدون خطا نمایش داده شوند.
## فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `src/Resource/Entity/ClinicResource.php` | منبع؛ امروز به `Doctor`/`ClinicStaff`/`Room` لینک می‌شود |
| `src/Resource/Repository/ClinicResourceRepository.php` | `findEligible()` — انتخاب کاندید با type+skill |
| `src/ClinicService/Entity/ServiceItem.php` | سرویس و گزینه (هر دو item) با `solo/additional` و قیمت |
| `src/ClinicService/Entity/ItemGroup.php` · `ItemGroupMember.php` | گروه گزینه‌ها با بازهٔ انتخاب |
| `src/ClinicService/Entity/CatalogCategory.php` | دستهٔ سراسری محیط؛ `parent` تک‌والدی و `MAX_DEPTH = 4` |
| `src/ClinicService/Service/ServiceSelectionValidator.php` | اعتبارسنجی ترکیب انتخاب‌ها |
| `src/ClinicService/Entity/ServiceBranchOverride.php` | override per شعبه (باید در زنجیره بماند) |
| `src/ClinicService/Service/DurationCalculator.php` | جمع solo/additional |
| `src/Pricing/Service/PricingEngine.php` | `quote()` — بدون بُعد منبع |
| `src/Appointment/Entity/Appointment.php` | `private Doctor $doctor` غیرتهی |
| `src/Appointment/Controller/AppointmentController.php` | `book()``doctor_uuid` الزامی |
| `src/Appointment/Plan/Service/AppointmentPlanBuilder.php` | ساخت برنامه و نیازمندی‌ها |
| `src/Appointment/Availability/Service/AvailabilityEngine.php` | جستجوی وقت آزاد روی منابع |
| `assets/admin/pages/ResourcesPage.tsx` | صفحهٔ منابع پنل |
| `docs/api/appointment.md` · `docs/api/clinic.md` | سند endpointها |
## وضعیت فعلی
انتخاب کاندید هیچ ربطی به سرویس ندارد — `src/Resource/Repository/ClinicResourceRepository.php:108`:
```php
public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array
{
$qb = $this->createQueryBuilder('r')
->where('r.address = :address')
->andWhere('r.type = :type')
->andWhere('r.active = true')
// … فیلتر مهارت
```
override فقط per شعبه است — `src/ClinicService/Entity/ServiceBranchOverride.php:41`:
```php
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
private ?int $priceRials = null;
#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
private ?int $soloDurationMinutes = null;
```
نوبت بدون پزشک ساخته نمی‌شود — `src/Appointment/Entity/Appointment.php:101` و `:242`:
```php
private Doctor $doctor;
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
```
قیمت‌گذاری بُعد منبع ندارد — `src/Pricing/Service/PricingEngine.php:59`:
```php
public function quote(
ServiceItem $service,
array $items,
DoctorAddress $address,
int $at,
array $policy = [],
?PatientRecord $patient = null,
): PriceQuote {
```
## وظایف
### ۱. جدول رابطهٔ منبع↔سرویس
`src/Resource/Entity/ResourceServiceOffering.php` — رابطهٔ چند‌به‌چند با تنظیمات اختصاصی.
همان الگوی `ResourceSkill`/`ResourcePoolMember`: entity رابطه‌ای با جفت یکتا.
```php
#[ORM\Entity(repositoryClass: ResourceServiceOfferingRepository::class)]
#[ORM\Table(name: 'resource_service_offerings')]
#[ORM\UniqueConstraint(name: 'uniq_resource_service', columns: ['resource_id', 'service_item_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'service_item_id'], name: 'idx_offering_tenant_service')]
class ResourceServiceOffering
{
use TenantOwnedTrait; // جفت از خود منبع مشتق می‌شود، نه از بدنهٔ درخواست
public function __construct(ClinicResource $resource, ServiceItem $serviceItem) { }
private ?int $durationMinutes = null; // null = ارث از سطح بالاتر
private ?int $priceRials = null; // null = ارث از سطح بالاتر
private bool $active = true;
}
```
نکته: چون «گزینه» هم `ServiceItem` است، همین یک جدول هر دو سطحِ سند را پوشش می‌دهد —
ردیف با آیتمِ والد = «منبع + سرویس»، ردیف با آیتمِ عضو گروه = «منبع + گزینه».
**نحوه تست:** migration ساخته و اجرا شود؛ سپس
`ddev exec php bin/phpunit tests/Resource/ResourceServiceOfferingTest.php` با سه تست:
ثبت ردیف، جفت تکراری → خطای یکتایی، و اینکه جفت محیط از منبع گرفته می‌شود نه از ورودی.
### ۲. Resolver مدت و قیمت
`src/ClinicService/Service/ResourceServiceResolver.php` — الگوی **Chain of Responsibility**،
چون سند صریحاً ترتیب اولویت تعریف کرده و افزودن سطح بعدی (مثلاً قرارداد بیمه) نباید
`if` تازه در دل موتور بگذارد.
ترتیب (از خاص به عام) — سطح شعبه عمداً در زنجیره می‌ماند چون داده‌اش امروز وجود دارد:
```
۱. منبع + گزینه → ResourceServiceOffering(resource, optionItem)
۲. منبع + سرویس → ResourceServiceOffering(resource, parentItem)
۳. شعبه + آیتم → ServiceBranchOverride(item, address)
۴. پیش‌فرض خودِ آیتم → ServiceItem::getSoloDurationMinutes() / getPriceRials()
```
```php
public function resolve(ClinicResource $resource, ServiceItem $item, DoctorAddress $address): ResolvedServiceSpec
{
// اولین سطحی که مقدارِ غیرnull دارد برنده است — مدت و قیمت **جدا** حل می‌شوند:
// منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
}
```
`ResolvedServiceSpec` باید بگوید هر مقدار از کدام سطح آمده (`durationSource`, `priceSource`) —
بدون این، دیباگِ «چرا این عدد؟» در پنل غیرممکن است.
**نحوه تست:** `tests/ClinicService/ResourceServiceResolverTest.php` — چهار تست، هر سطح یکی، به‌علاوهٔ
تستِ مرزی «مدت از سطح ۱ و قیمت از سطح ۳».
### ۳. فیلتر کاندیدها بر اساس سرویس
`ClinicResourceRepository::findEligible()` یک آرگومان اختیاری `?ServiceItem $service` بگیرد و
وقتی داده شد، فقط منابعی برگردد که ردیف فعال در `resource_service_offerings` دارند.
**قاعدهٔ سازگاری عقب‌رو:** اگر برای آن سرویس **هیچ** ردیفی ثبت نشده باشد، فیلتر اعمال نشود
(همان رفتار امروز). وگرنه هر محیطی که هنوز رابطه‌ها را پر نکرده، یک‌شبه بدون وقت آزاد می‌شود.
`AppointmentPlanBuilder::planRequirements()` سرویس را به `eligibleFor()` پاس بدهد.
**نحوه تست:** دو منبع از یک نوع بساز، فقط یکی را به سرویس وصل کن، `POST /api/v1/appointment-availability`
بزن و مطمئن شو `assignment` همیشه همان یک منبع است. سپس ردیف را غیرفعال کن → `slots` خالی با `reason`.
### ۴. منبع و snapshot روی نوبت
- `Appointment::$resource` (`ManyToOne`, **nullable**) + `Appointment::$serviceOptionItem` (nullable).
nullable بودن اجباری است: ۷۲ نوبت موجود منبع ندارند و migration نباید آن‌ها را بشکند.
- `service_total_minutes` و `PriceSnapshot` از قبل هستند — فقط باید از خروجی resolver پر شوند،
نه از `ServiceItem` مستقیم.
- `Appointment::toArray()` باید `resource` (`uuid`, `name`, `type`) و `service_option` را برگرداند.
**نحوه تست:** `tests/Appointment/ResourceBookingTest.php` — رزرو با منبع، سپس تغییر قیمت سرویس و
اطمینان از اینکه `price_snapshot` نوبت قبلی تکان نمی‌خورد (همان قاعدهٔ snapshot سند).
### ۵. رزرو با منبع در endpointها
- `POST /api/v1/appointment`: `resource_uuid` پذیرفته شود. اگر آمد، `doctor_uuid` اختیاری است و
پزشک از `resource->subject()` استنتاج می‌شود (اگر منبع پزشک باشد). اگر منبع دستگاه باشد و
نوبت پزشک ندارد، `Appointment.doctor` باید nullable شود — این تغییر schema است و migration جدا می‌خواهد.
- `GET /api/v1/appointment-booking-services/{doctorUuid}` مکمل بگیرد:
`GET /api/v1/resource/{uuid}/services` → سرویس‌های آن منبع با مدت و قیمتِ **حل‌شده**.
- `POST /api/v1/appointment` بدون هیچ‌کدام → `422`.
**نحوه تست:** با `curl` و توکن بیمار روی دادهٔ `app:seed-scenarios`: یک‌بار با `resource_uuid` دستگاه،
یک‌بار با `doctor_uuid` (مسیر قدیمی باید سالم بماند)، یک‌بار با منبعِ بی‌ارتباط → `422`.
### ۶. پنل ادمین: تب «سرویس‌های این منبع»
در `assets/admin/pages/ResourcesPage.tsx` (یا صفحهٔ جزئیات منبع) جدولی با ستون‌های
سرویس/گزینه · مدت · قیمت · فعال، با ویرایش inline. خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر» و
باید مقدار مؤثر را به‌صورت placeholder با برچسب منبعش نشان دهد (`از شعبه`، `پیش‌فرض سرویس`).
از `SearchableSelect` استفاده شود، نه `<select>` خام؛ فرم با React Hook Form + Zod؛ داده با TanStack Query.
**صفحهٔ تازه ساخته نشود** — این یک تب روی صفحهٔ منبع موجود است. جدول با `DataTable` و ستون وضعیت با
`StatusBadge`؛ حذف رابطه با `ConfirmDialog`. طبق قاعدهٔ ۱، هیچ رنگ خام و هیچ کامپوننت موازی.
**نحوه تست:** `npx vitest run assets/admin/pages/ResourceServicesTab.test.tsx` — سه تست:
نمایش مقدار مؤثر، ذخیرهٔ override، پاک‌کردن override → بازگشت به ارث.
سپس **بازبینی چشمی** در مرورگر: دارک‌مود، حالت فشرده و موبایل ۳۹۰px — هر سه بدون شکستگی و
بدون اسکرول افقی. بدون این سه اسکرین‌شات، وظیفه تمام‌شده نیست.
### ۷. دسته‌بندی سراسری کلینیک، مشترک بین سرویس و منبع، با «شامل بودن»
سند مالک محصول: دسته‌بندی باید در سطح کلینیک تعریف شود و **هم سرویس‌ها هم منابع** از همان
استفاده کنند؛ و یک دسته می‌تواند شامل دسته‌های دیگر باشد («تمام بدن» شامل دست و پا و …).
**وضعیت امروز:** `CatalogCategory` از قبل سراسریِ محیط است (جفت `(entity_type, entity_id)` +
`parent` + `MAX_DEPTH = 4`) و روی `ServiceItem::$catalogCategory` می‌نشیند. دو چیز کم است:
الف) `ClinicResource` هیچ فیلد دسته‌ای ندارد، پس «این دستگاه برای دست و پا است» گفتنی نیست.
ب) **`parent` برای «شامل بودن» کافی نیست.** درخت تک‌والدی است: «دست» نمی‌تواند هم‌زمان زیر
«تمام بدن» و زیر «اندام فوقانی» باشد، در حالی که در لیزر مجموعه‌ها روی هم می‌افتند. پس
containment یک **گراف جهت‌دار بدون دور** است، جدا از سلسله‌مراتب نمایشی.
سه تغییر:
```php
// ۱) عضویت چندگانهٔ منبع در دسته‌ها — m2m، چون یک دستگاه چند ناحیه را پوشش می‌دهد
#[ORM\Table(name: 'resource_catalog_categories')]
#[ORM\UniqueConstraint(name: 'uniq_resource_category', columns: ['resource_id', 'category_id'])]
// ۲) یال «شامل بودن» بین دسته‌ها — DAG، نه درخت
#[ORM\Table(name: 'catalog_category_includes')]
#[ORM\UniqueConstraint(name: 'uniq_category_include', columns: ['parent_category_id', 'child_category_id'])]
class CatalogCategoryInclude
{
// «تمام بدن» → «دست» ، «تمام بدن» → «پا» …
// parent === child ممنوع، و بستارِ گذرا نباید به خودش برگردد.
}
```
```php
// ۳) بستار گذرا: «تمام بدن» شامل «نیم‌تنهٔ پایین» و آن شامل «پا» ⇒ تمام بدن شامل پا
final class CategoryClosureResolver
{
/** @return int[] شناسهٔ همهٔ دسته‌های زیرمجموعه، با پیمایش عمقی و محافظ دور */
public function descendants(CatalogCategory $category): array { }
public function overlaps(CatalogCategory $a, CatalogCategory $b): bool { }
}
```
مصرفش در دو نقطه:
- **تعارض انتخاب:** `ServiceSelectionValidator` وقتی دو آیتم انتخاب‌شده دسته‌هایی دارند که یکی
دیگری را شامل می‌شود → `422` با پیام «تمام بدن شامل دست است؛ هر دو با هم انتخاب نمی‌شوند».
این جای رابطهٔ دستیِ `incompatible_with` را برای این حالت می‌گیرد — یک بار در دسته تعریف
می‌شود، نه به‌ازای هر جفت آیتم.
- **فیلتر منبع:** در `findEligible()` (وظیفهٔ ۳) اگر سرویس دسته دارد، منابعی که آن دسته یا یکی از
اجدادش را پوشش می‌دهند مقدم‌اند.
**حلقه ممنوع:** پیش از ذخیرهٔ یال، `descendants($child)` بررسی شود و اگر `$parent` در آن بود
`422` برگردد. بدون این، `descendants()` تا سرریز استک می‌رود.
**نحوه تست:** `tests/ClinicService/CategoryClosureTest.php`
✅ «تمام بدن» → دست/پا ثبت شود و `descendants` هر دو را بدهد ·
✅ زنجیرهٔ سه‌سطحی بستار گذرا را درست بدهد ·
❌ یال دوری («دست شامل تمام بدن») → `422` ·
⚠️ دسته‌ای که هیچ یالی ندارد → آرایهٔ خالی، نه خطا.
سپس با `curl`: انتخاب هم‌زمان «لیزر تمام بدن» و «لیزر دست» در `POST /api/v1/appointment``422`.
### ۸. حذف زیرسیستم‌های خارج از این مدل
**تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰):** هر چیزی خارج از منبع/سرویس/گزینه از محصول حذف شود.
ریسکش گفته شد (جریمهٔ لغو و پکیج معمولاً نیاز واقعی کلینیک‌اند) و مالک محصول تصمیم را تکرار کرد.
حذف کامل این پنج زیرسیستم — کد، جدول، endpoint، تست، صفحهٔ پنل، سند:
| دامنه | مسیر | حجم |
|---|---|---|
| موتور سیاست | `src/Policy/` | ۲۷ فایل · ۶ فایل تست |
| پکیج و دفتر اعتبار | `src/Package/` | ۱۳ فایل · ۲ تست |
| دورهٔ درمان | `src/Course/` | ۱۴ فایل · ۲ تست |
| لغو/جریمه/لیست انتظار | `src/Cancellation/` + `src/Waitlist/` | ۱۹ فایل · ۲ تست |
| رویداد و گزارش | `src/Report/` + `src/Shared/Event/` | ۳ فایل · ۳ تست |
صفحات پنل: `PolicyFormPage`, `PolicySimulationPage`, `PackagesPage`, `PatientPackageLedgerPage`,
`CourseProtocolsPage`, `CancellationPolicyPage`, `ResourceUtilizationPage` (+ تست‌هایشان) و مسیرهایشان در `App.tsx`.
**قلاب‌هایی که باید از کد باقی‌مانده کنده شوند** (اینجا کامپایل می‌شکند، پس ترتیب مهم است):
```
src/Appointment/Plan/Service/AppointmentPlanBuilder.php → applyTimingPolicies() و applyResourcePolicies()
src/Pricing/Service/PricingEngine.php → PricingPolicyEngine و PackageConsumptionService
src/Appointment/Booking/Controller/BookingController.php → BookingPolicyGuard
src/ClinicService/Service/ServiceSelectionValidator.php → SelectionPolicyEngine
src/Appointment/Booking/Service/BookingService.php → PackageConsumptionService، CreditLedgerService، CourseSessionLinker
src/Shared/Tenant/GlobalTables.php → ردیف‌های همین دامنه‌ها
src/Shared/Command/BookingEngineSeeder.php → متدهای policies/packagesAndCourses/cancellationAndWaitlist
```
migration جدا برای `DROP TABLE` جدول‌های این دامنه‌ها با `down()` واقعی.
**نحوه تست:** بعد از حذف: `ddev exec php bin/phpunit` کامل سبز ·
`ddev exec php vendor/bin/phpstan analyse` روی همان baseline ۱۴ خطا ·
`ddev exec npx tsc --noEmit` بدون خطا · `ddev exec php bin/console debug:router | grep -cE "policy|package|course|waitlist|cancellation"``0` ·
`ddev exec php bin/console app:seed-scenarios --reset -n` بدون خطا.
### ۹. مستندات
- `docs/api/appointment.md`: فیلد `resource_uuid` در رزرو + پاسخ `resource`/`service_option`، و حذف
بخش‌های سیاست/پکیج/دوره/لغو.
- `docs/api/clinic.md`: endpoint جدید `resource/{uuid}/services`.
- `docs/architecture/`: سند مدل منبع‌محور با همان چهار سطح زنجیره.
- `docs/new_feture/taskes/`: چک‌لیست تسک‌های ۹ تا ۱۴ با وضعیت «حذف‌شده به تصمیم مالک محصول» و تاریخ.
- `TEST_USERS.md`: جدول «موتور نوبت‌دهی» باید سطرهای حذف‌شده را از دست بدهد.
## نکات مهم
- **خط قرمز:** منطق نوبت‌دهی اسلاتی نباید تغییر کند. `ddev exec php bin/phpunit --group=slot-mode-frozen`
باید در هر مرحله سبز بماند.
- **چرا جدول `service_options` جدید نمی‌سازیم:** گزینه از قبل `ServiceItem` است و `ItemGroup`
قواعد «حداقل یکی، حداکثر سه‌تا» را دارد. جدول سوم یعنی `PriceListItem`، `Tariff`،
`appointment_service_items`، `SegmentTemplate` و کل مسیر `appointment-service-slots` باید دو نوع
ورودی بشناسند — دو منبع حقیقت برای یک مفهوم.
- **مدت و قیمت جدا حل می‌شوند.** منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
- **جفت محیط (`entity_type`,`entity_id`) از خود منبع مشتق شود**، نه از بدنهٔ درخواست — همان قاعده‌ای که
`ClinicResource` و `ServiceItem` رعایت می‌کنند. `TenantSchemaCoverageTest` entity طبقه‌بندی‌نشده را قرمز می‌کند.
- **سازگاری داده:** `resource_id` و `service_option_item_id` روی نوبت nullable؛ فیلتر سرویس در
`findEligible` فقط وقتی رابطه‌ای ثبت شده باشد.
- **cross-repo:** بعد از تغییر قرارداد، مصرف واقعی در `nobat724_front/services/response.js` و
`nobat724_front/components/appointment/` دستی بررسی شود؛ سایت امروز فقط `doctor_uuid` می‌فرستد و
با اختیاری‌شدنش نمی‌شکند، ولی برای رزرو دستگاه باید به‌روز شود.
- **ترتیب اجرا:** اول وظیفهٔ ۷ (حذف) یا اول ۱ تا ۶؟ حذف اول انجام شود — وگرنه resolver و
`PlanBuilder` را دوبار می‌نویسی: یک‌بار با قلاب سیاست، یک‌بار بدون آن.
@@ -0,0 +1,520 @@
# آدیت امنیتی دلتایی — سطح حملهٔ ساخته‌شده بعد از ۲۰۲۶-۰۷-۱۹
## زمینه
آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا
امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آن‌ها دقیقاً روی سطحی است
که آدیت قبلی ندیده بود:
- رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog`
- بازنویسی گیت‌های منشی و پزشکِ مهمانِ کلینیک
- دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل
- نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber`
- `PatientRecordScopeResolver` برای محدودکردن دید پرونده‌ها
آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر
`admin` و `doctor` در DB بود. آن محدودیت حالا برطرف‌شدنی است.
این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافته‌های قبلی فقط regression می‌شوند،
و بودجهٔ اصلی صرف کدی می‌شود که هرگز آدیت نشده.
## مشکل / هدف
**مشکل:** بزرگ‌ترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچ‌وقت
تست امنیتی نشده. یک رجیستری مجوز که در UI رندر می‌شود ولی در backend enforce نشود، یعنی هر
منشی/پزشکِ مهمان می‌تواند با یک درخواست مستقیم به API از مجوزش فرار کند.
**هدف:** پیدا کردن و رفع آسیب‌پذیری‌های این سطح، به‌علاوهٔ تأیید اینکه یافته‌های آدیت قبلی
برنگشته‌اند.
**سیاست رفع (تصمیم کاربر):**
- 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود.
- 🟨 Medium و پایین‌تر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن.
**خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به
پکیج umbrella `ckeditor5` v45+. فقط به‌عنوان «risk پذیرفته‌شده» در گزارش ثبت شود، پیاده نشود.
## معیار پذیرش
-**موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید
اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که
ثابت می‌کند خاموش‌بودن آن مجوز، درخواستِ متناظر API را با **403** رد می‌کند — نه اینکه فقط
دکمه را در UI پنهان کند.
-**خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر →
**403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ
tenant دیگر و هرگز 500 با stack trace.
- ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از
`POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو
نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله
مسیرهایی که بعد از نوشتن گارد اضافه شده‌اند ــ 403 می‌گیرد.
- ✅ تست‌ها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
- ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه به‌روز شد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** |
| `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقش‌ها (`roles`) |
| `docs/security/AUDIT-2026-07-19.md` | یافته‌های قبلی برای regression |
| `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمی‌تر) |
| `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشن‌های مجوزدهی |
| `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` |
| `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی |
| `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان |
| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان |
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل |
| `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل |
| `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار |
| `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان |
| `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس |
| `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل |
| `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاه‌ها |
| `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط |
| `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط |
| `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant |
| `config/packages/security.yaml` | firewall و مسیرهای public |
| `assets/admin/` | پنل ادمین؛ مجوزهای UI |
| `docs/architecture/tenancy.md` | «چه تضمین می‌دهد و چه نمی‌دهد» |
## وضعیت فعلی
### ۱. عدم تقارن گیت در دامنهٔ Treatment — قوی‌ترین lead
`TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متد‌به‌متد می‌سنجد:
```php
// src/Treatment/Controller/TreatmentCaseController.php:31
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TreatmentCaseController extends BaseController
{
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$case = $this->requireCase($user, $uuid);
// ...
}
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
// ...
}
}
```
ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی
`PUT`، نه روی `DELETE`:
```php
// src/Treatment/Controller/TreatmentProtocolController.php:24
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TreatmentProtocolController extends BaseController
{
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data);
return $this->success($protocol->toArray());
}
}
```
یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز
اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشی‌ای که مجوز `services` ندارد — می‌تواند پروتکل
درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.**
### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر
```php
// src/Staff/Security/StaffRouteGuardSubscriber.php
private const ALLOWED_PREFIXES = [
'/api/v1/dashboard/staff',
'/api/v1/auth/switch-context',
'/api/v1/user/change-password',
];
private const OVERRIDING_ROLES = [
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
];
public function onKernelRequest(RequestEvent $event): void
{
// ...
$path = $event->getRequest()->getPathInfo();
if (!str_starts_with($path, '/api/')) {
return;
}
// ...
foreach (self::ALLOWED_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return;
}
}
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
}
```
دو ریسک ساختاری که باید تست شوند:
- گارد روی `getPathInfo()` و `str_starts_with` کار می‌کند. مسیر نرمال‌نشده
(`/api/v1/../v1/patients`، دابل‌اسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از
allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه.
- `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار می‌گذارد. اگر کاربری هم پرسنل
و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمی‌شود.
### ۳. یافته‌های باز از ۲۰۲۶-۰۷-۱۹
| # | یافته | شدت | وضعیت ثبت‌شده |
|---|-------|-----|----------------|
| 2 | `lodash` code injection via `_.template` | 🟧 High | نیمه‌رفع |
| 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت |
| 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز |
### ۴. وضعیت اکانت‌های تست
`TEST_USERS.md` بی‌اعتبار است و `create_test_users.php` که به آن ارجاع می‌دهد در repo نیست.
**وضعیت واقعی DB لوکال، سنجیده‌شده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی
`driver.mjs roles` و `ddev mysql`:
```
admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق
representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق
doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست
clinic 09127000000 — ✗ کاربر در DB نیست
secretary 09123456778 — ✗ کاربر در DB نیست
```
DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند:
```
id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است
id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است
id=24 0912000301 ROLE_USER,ROLE_CLINIC
id=5 0912000109 ROLE_USER,ROLE_SECRETARY
id=15 0912000209 ROLE_USER,ROLE_SECRETARY
id=28 0912000309 ROLE_USER,ROLE_SECRETARY
```
پس کاربر پرسنل و کاربر چندنقشی **ساخته نمی‌شوند** — فقط پسوردشان باید معلوم/ست شود.
پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش.
---
## وظایف
### ۱. آماده‌سازی ماتریس نقش‌ها
اول وضعیت واقعی اکانت‌ها را بسنج، بعد کمبود را بساز:
```bash
ddev start
node .claude/skills/qa-clinicpro/driver.mjs roles
```
کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته
نمی‌شوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن:
```bash
ddev exec php bin/console security:hash-password 'QaTest@1234'
ddev mysql -e "UPDATE users SET password_hash='<hash>' WHERE mobile_number='09128726723';"
```
این تغییر فقط روی DB لوکال است و در گزارش ثبت می‌شود.
علاوه بر آن، برای تست IDOR لازم است:
- **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار.
اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز.
خروجی این وظیفه یک جدول creds در ابتدای گزارش است.
**نحوه تست:** برای هر کاربر ساخته‌شده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در
گزارش، `roles` هر توکن decode‌شده ثبت شود.
---
### ۲. اجرای درایور به‌عنوان baseline
**با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق
`.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config —
black-box: authz/headers/cors/injection).
خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و
leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹
رعایت کرده بود.
**نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود.
---
### ۳. regression یافته‌های آدیت قبلی
برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان
گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است:
```bash
# CSP روی SPA ادمین
curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy
# APP_SECRET در فایل env تحت git
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET='
# فلگ‌های session cookie
ddev exec php bin/console debug:config framework session
# وابستگی‌های npm
npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)"
```
هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت می‌شود — چون قبلاً رفع شده بوده
و دوباره شکسته.
**نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش.
---
### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت
`PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این
سه سؤال جواب داده شود:
1. کدام اندپوینت(ها) این مجوز را نمایندگی می‌کنند؟
2. آیا backend واقعاً enforce می‌کند، یا فقط UI دکمه را پنهان می‌کند؟
3. آیا منشی و پزشکِ مهمان **هر دو** enforce می‌شوند، یا فقط یکی؟
روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن.
```bash
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])')
# مجوز treatment.update خاموش است → باید 403 بدهد
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid> \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"status":"closed"}'
```
هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود
عمداً باز است — که آن‌وقت باید در `docs/api/` مستند باشد.
خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش.
**نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد.
---
### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت
سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً:
الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن
`requireItem()` دقیقاً چه می‌سنجد — فقط tenant، یا مجوز اکشن هم؟
```bash
# با توکن منشیِ بدون مجوز services
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
https://clinic-pro.ddev.site/api/v1/service-item/<uuid>/treatment-protocol \
-H "Authorization: Bearer $SECRETARY_TOKEN"
# انتظار: 403 — اگر 200/204 داد، یافتهٔ High
```
ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر:
```
GET /api/v1/treatment-case/{uuid}
PATCH /api/v1/treatment-case/{uuid}
GET /api/v1/treatment-case/{uuid}/plan
GET /api/v1/treatment-session/{uuid}
GET /api/v1/treatment-session/{uuid}/slot-suggestions
GET /api/v1/service-item/{uuid}/treatment-protocol
PUT /api/v1/service-item/{uuid}/treatment-protocol
DELETE /api/v1/service-item/{uuid}/treatment-protocol
POST /api/v1/dashboard/staff/treatment-session/{uuid}/start
POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish
POST /api/v1/dashboard/staff/session-area/{uuid}/start
POST /api/v1/dashboard/staff/session-area/{uuid}/complete
POST /api/v1/dashboard/staff/session-area/{uuid}/skip
POST /api/v1/dashboard/staff/session-area/{uuid}/reopen
```
انتظار: 403 یا 404. هر 200 یا 500 یافته است.
ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد
شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد می‌شود.
د) **دستکاری وضعیت.** `finish` روی جلسه‌ای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته،
`complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود.
**نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit
در `tests/` که آن مسیر را با کاربر بیگانه می‌زند و 403 انتظار دارد.
---
### ۶. آدیت فرار از tenant
`docs/architecture/tenancy.md` صریح می‌گوید `TenantFilter` یک تور ایمنی است، نه authorization،
و روی سه چیز اعمال **نمی‌شود**: SQL خام، `getReference()`، و فرزندان aggregate.
```bash
# SQL خام
grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php
# getReference
grep -rn "getReference(" src/ --include=*.php
# entityهای بدون طبقه‌بندی tenant
ddev exec php bin/phpunit --filter TenantSchemaCoverageTest
```
هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری می‌شود؟ tenant دستی چک شده؟
**نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر می‌گیرد،
یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتری‌سازی شده.
---
### ۷. آدیت گاردِ پرسنل
الف) **نرمال‌سازی مسیر** (بخش «وضعیت فعلی ۲»):
```bash
for P in \
'/api/v1/patients' \
'/api//v1/patients' \
'/api/v1/dashboard/staff/../../patients' \
'/api/v1/%2e%2e/v1/patients' \
'/API/v1/patients' ; do
printf '%s -> ' "$P"
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \
"https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN"
done
```
هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است.
ب) **کشف مسیرهای تازه‌ای که گارد نمی‌بیند.** فهرست کامل روت‌ها را بگیر و همه را با توکن پرسنل
بزن:
```bash
ddev exec php bin/console debug:router --format=json > /tmp/routes.json
```
اندازهٔ واقعی (سنجیده‌شده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت
`GET` و **۱۲۸** روت `GET` بدون path parameter.
**سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و
404 آن با 403 قابل تفکیک نیست. روت‌های پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده
می‌شوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع.
برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است.
ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز
به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل
بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقش‌ها.
**نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش.
---
### ۸. آدیت پنل ادمین
مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را
پنهان می‌کند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را می‌دهد؛ اینجا فقط
نگاشت UI به endpoint ثبت شود.
علاوه بر آن:
- توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را
بدون sanitize رندر نمی‌کند (`dangerouslySetInnerHTML`).
- CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای
CSP در کنسول نمی‌دهند.
```bash
grep -rn "dangerouslySetInnerHTML" assets/admin/
```
**نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را
می‌دهد، از همان استفاده کن.
---
### ۹. رفع
طبق سیاست تعیین‌شده:
- **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف
بزرگ.
- **Medium و پایین‌تر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**.
قواعد پروژه هنگام رفع:
- گیت جدید در همان لایه‌ای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه
یک مکانیزم موازی جدید.
- منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection.
- خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از
`src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همان‌جا اضافه شود.
- اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`.
**نحوه تست:**
```bash
ddev exec php bin/phpunit
ddev exec php vendor/bin/phpstan analyse
```
---
### ۱۰. گزارش
فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹:
1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت`
2. جدول creds نقش‌ها که آدیت با آن اجرا شد
3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمال‌شده، علت انتخاب
راه‌حل
4. جدول کامل ماتریس مجوز (وظیفهٔ ۴)
5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند
6. «leadهایی که رد شدند» با دلیل
7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا
8. «risk پذیرفته‌شده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز
---
## نکات مهم
- **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نمی‌شود. خروجی درایور lead است، نه
یافته. این قانون از گزارش قبلی می‌آید و باید حفظ شود.
- **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و
کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو.
- **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط می‌گوید کاربر پرسنل است، نه اینکه این جلسه
مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است.
- **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate
اعمال نمی‌شود. `docs/architecture/tenancy.md` مرجع است.
- **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش
یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن.
- **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست
تست شود که هنوز 200 می‌دهد.
- **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر
ثبت شود. این قانون ایستادهٔ پروژه است.
- **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف
می‌کند، در گزارش هشدار بده — build کلاینت خطا نمی‌دهد.
- **بدون DoS.** آدیت روی محیط لوکال ddev اجرا می‌شود. تست rate limit با چند درخواست شمارشی، نه
با سیل ترافیک.
- **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده.
+325
View File
@@ -0,0 +1,325 @@
# هفت لندینگ‌پیج سئویی با تم صفحهٔ اصلی
## زمینه
سایت عمومی کلینیک‌پرو الان فقط یک صفحه دارد: `/` که با
`src/Shared/Controller/HomeController.php` رندر می‌شود و کل محتوایش در یک فایل
۹۹۳ خطی `templates/public/home.html.twig` هاردکد است — شامل `<head>` کامل، متای سئو،
JSON-LD، هدر، شش بخش محتوا، مودال ثبت‌نام و فوتر.
`sitemap.xml` هم فقط همان یک URL را دارد
(`src/Shared/Controller/SeoController.php`).
برای هفت کلیدواژهٔ تجاری، هفت صفحهٔ فرود جدا لازم است. هرکدام باید عنوان، توضیحات،
canonical، `h1` و متن مخصوص خودش را داشته باشد، ولی ظاهرش دقیقاً همان تم صفحهٔ اصلی
باشد.
## مشکل / هدف
هفت لندینگ با این کلیدواژه‌ها:
| کلیدواژه | آدرس |
|---|---|
| نرم‌افزار مدیریت کلینیک زیبایی | `/نرم-افزار-مدیریت-کلینیک-زیبایی` |
| نرم‌افزار مدیریت کلینیک دندان‌پزشکی | `/نرم-افزار-مدیریت-کلینیک-دندانپزشکی` |
| نرم‌افزار مدیریت کلینیک فیزیوتراپی | `/نرم-افزار-مدیریت-کلینیک-فیزیوتراپی` |
| سیستم‌های جامع درمانگاهی | `/سیستم-جامع-درمانگاهی` |
| نرم‌افزار مجانی مدیریت کلینیک | `/نرم-افزار-رایگان-مدیریت-کلینیک` |
| نرم‌افزار مدیریت مطب | `/نرم-افزار-مدیریت-مطب` |
| CRM کلینیک‌ها | `/crm-کلینیک` |
**تصمیم معماری (تأییدشده):** یک قالب مشترک + رجیستری PHP. کپی‌کردن
`home.html.twig` هفت بار یعنی هر تغییر تم باید هشت بار تکرار شود و بعد از دو ماه هشت
نسخهٔ واگرا داریم. متن هر صفحه داده است، نه کد.
## معیار پذیرش
- ✅ موفق: `GET /نرم-افزار-مدیریت-کلینیک-زیبایی` → ۲۰۰ با `<title>` و
`<meta name="description">` و `<link rel="canonical">` و `<h1>` مخصوص همان صفحه؛
ظاهرش همان تم صفحهٔ اصلی است (همان CSS، همان هدر و فوتر)؛ مودال «ثبت نام» باز
می‌شود و فرمش به `/api/v1/pre-registration` ارسال می‌کند.
- ❌ خطا: `GET /یک-اسلاگ-ناموجود` → ۴۰۴ استاندارد Symfony، نه صفحهٔ خالی و نه ۲۰۰
با محتوای پیش‌فرض.
- ⚠️ مرزی: `GET /sitemap.xml` هر هشت URL را دارد (خانه + هفت لندینگ) و XML معتبر
است؛ هیچ دو صفحه‌ای `title` یا `h1` یا `canonical` یکسان ندارند؛ روی دامنهٔ
`.ddev.site` مقدار `robots.txt` همچنان `Disallow: /` می‌ماند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Shared/Controller/HomeController.php` | کنترلر فعلی صفحهٔ اصلی |
| `src/Shared/Controller/SeoController.php` | robots.txt و sitemap.xml — باید لندینگ‌ها را بشناسد |
| `templates/public/home.html.twig` | تم مرجع؛ هدر، فوتر، مودال و بخش‌ها از اینجا می‌آیند |
| `assets/home/styles.css` | تمام کلاس‌های تم (`hero`, `fcols`, `band`, `devices`, `specs`, `btn-*`, `wrap`) |
| `webpack.config.js` | entry به‌نام `home` که لندینگ‌ها هم از آن استفاده می‌کنند |
| `src/Shared/Landing/` | **جدید** — رجیستری و مدل لندینگ |
| `templates/public/landing.html.twig` | **جدید** — قالب مشترک |
| `templates/public/_reg_modal.html.twig` | **جدید** — مودال ثبت‌نام، مشترک بین خانه و لندینگ‌ها |
## وضعیت فعلی
کنترلر فعلی:
```php
class HomeController extends AbstractController
{
public function __construct(private readonly AltchaService $altcha) {}
#[Route('/', name: 'home', methods: ['GET'])]
public function index(): Response
{
return $this->render('public/home.html.twig', [
'altcha_enabled' => $this->altcha->enabled(),
]);
}
}
```
`sitemap.xml` فقط یک URL دارد:
```php
#[Route('/sitemap.xml', name: 'seo_sitemap', methods: ['GET'])]
public function sitemap(Request $request): Response
{
$base = $request->getSchemeAndHttpHost();
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"
. ' <url>' . "\n"
. " <loc>{$base}/</loc>\n"
. ' <changefreq>weekly</changefreq>' . "\n"
. ' <priority>1.0</priority>' . "\n"
. ' </url>' . "\n"
. '</urlset>' . "\n";
```
اسکلت صفحهٔ اصلی — خطوطی که مرزهای قابل استخراج‌اند:
```
66: {{ encore_entry_link_tags('home') }}
74: <header class="site-header" id="header"> … تا 99
101: <main> … تا 744
600: <script> ← منطق مودال ثبت‌نام (submitReg)
747: <footer class="site-footer" id="contact"> … تا 822
991: {{ encore_entry_script_tags('home') }}
```
مودال ثبت‌نام به `altcha_enabled` وابسته است و به این اندپوینت می‌فرستد:
```js
fetch('/api/v1/pre-registration', {
```
## وظایف
### ۱. مدل و رجیستری لندینگ
`src/Shared/Landing/LandingPage.php` — یک value object فقط‌خواندنی:
```php
final readonly class LandingPage
{
/**
* @param list<array{title: string, body: string}> $features
* @param list<array{q: string, a: string}> $faq
* @param list<string> $related اسلاگ لندینگ‌های مرتبط
*/
public function __construct(
public string $slug,
public string $metaTitle,
public string $metaDescription,
public string $keywords,
public string $h1,
public string $lead,
public array $features,
public array $faq,
public array $related,
) {}
}
```
`src/Shared/Landing/LandingRegistry.php` — تنها منبع تعریف هفت صفحه:
```php
final class LandingRegistry
{
/** @return array<string, LandingPage> کلید = اسلاگ */
public function all(): array { }
public function find(string $slug): ?LandingPage { }
}
```
**چرا رجیستری و نه دیتابیس:** متن این صفحات محتوای بازاریابی است و با کد دیپلوی
می‌شود؛ جدول و CRUD برای هفت رکوردِ کم‌تغییر، پیچیدگی بی‌مصرف است (guidelines §۵).
اگر بعداً لازم شد از پنل ویرایش شود، همین interface جای تعویض دارد.
**نحوه تست:** یک تست PHPUnit در `tests/Shared/LandingRegistryTest.php`:
هر هفت اسلاگ وجود دارند؛ هیچ `metaTitle` یا `h1` تکراری نیست؛ طول
`metaDescription` بین ۱۲۰ و ۱۶۰ نویسه است؛ هر `related` به اسلاگی اشاره می‌کند که
واقعاً در رجیستری هست.
### ۲. استخراج هدر، فوتر و مودال از صفحهٔ اصلی
سه partial بساز و `home.html.twig` را طوری تغییر بده که همان‌ها را `include` کند —
یعنی خروجی رندرشدهٔ `/` **هیچ تغییری نکند**:
- `templates/public/_header.html.twig` (خطوط ۷۴ تا ۹۹)
- `templates/public/_footer.html.twig` (خطوط ۷۴۷ تا ۸۲۲)
- `templates/public/_reg_modal.html.twig` (مودال + اسکریپت `submitReg`)
**نحوه تست:** خروجی `/` را قبل و بعد ذخیره کن و diff بگیر؛ باید یکسان باشد:
```bash
ddev exec curl -s http://localhost/ > /tmp/home-before.html
# … بعد از تغییر
ddev exec curl -s http://localhost/ > /tmp/home-after.html
diff /tmp/home-before.html /tmp/home-after.html # باید خالی باشد
```
### ۳. قالب مشترک لندینگ
`templates/public/landing.html.twig` — همان اسکلت `home.html.twig` ولی داده‌محور:
- `<head>` با `page.metaTitle`، `page.metaDescription`، `page.keywords` و
`<link rel="canonical" href="{{ base }}/{{ page.slug }}">`
- `{{ encore_entry_link_tags('home') }}` و `{{ encore_entry_script_tags('home') }}`
همان entry، پس تم دقیقاً یکی است و CSS دومی ساخته نمی‌شود
- هدر و فوتر و مودال با `include` از وظیفهٔ ۲
- بخش hero با `page.h1` و `page.lead` و همان کلاس‌های `hero`, `hero-grid`,
`hero-copy`, `btn btn-coral`, `btn btn-blue`
- بخش امکانات با کلاس‌های `fcols`, `fcols-grid`, `fcol` روی `page.features`
- بخش پرسش‌های متداول روی `page.faq`
- بخش «راهنماهای مرتبط» با لینک داخلی به `page.related` — لینک داخلی بین لندینگ‌ها
برای سئو لازم است و صفحات را یتیم نمی‌گذارد
**قید مهم:** هیچ کلاس CSS جدیدی تعریف نکن. اگر بخشی از تم لازم است که کلاسش وجود
ندارد، از همان بخش‌های موجود (`band`, `specs`, `devices`) استفاده کن. تم باید یکی
بماند، نه شبیه.
**نحوه تست:** بعد از وظیفهٔ ۴، هر هفت آدرس را باز کن و با اسکرین‌شات با `/` مقایسه
کن.
### ۳.۱ JSON-LD هر صفحه
در قالب، سه schema بگذار:
- `SoftwareApplication` با `applicationCategory: "BusinessApplication"` و
`offers` — برای صفحهٔ رایگان `price: "0"`
- `FAQPage` از `page.faq` — این برای کلیدواژه‌های تجاری در نتایج گوگل شانس
rich result دارد
- `BreadcrumbList` با دو سطح: خانه ← همین صفحه
**نحوه تست:** خروجی هر صفحه را در
[validator.schema.org](https://validator.schema.org) بگذار، یا حداقل با
`python3 -c "import json,sys; json.load(sys.stdin)"` روی محتوای هر بلاک
`application/ld+json` صحت JSON را بسنج — Twig با نقل‌قول فارسی راحت JSON را خراب
می‌کند.
### ۴. کنترلر لندینگ
`src/Shared/Controller/LandingController.php`:
```php
final class LandingController extends AbstractController
{
public function __construct(
private readonly LandingRegistry $registry,
private readonly AltchaService $altcha,
) {}
#[Route('/{slug}', name: 'landing_show', methods: ['GET'], priority: -10)]
public function show(string $slug): Response
{
$page = $this->registry->find($slug);
if ($page === null) {
throw $this->createNotFoundException();
}
return $this->render('public/landing.html.twig', [
'page' => $page,
'altcha_enabled' => $this->altcha->enabled(),
]);
}
}
```
**چرا `priority: -10`:** روت `/{slug}` هر مسیر تک‌بخشی را می‌گیرد. بدون اولویت
منفی، ممکن است جلوی `/admin`، `/robots.txt` یا مسیرهای دیگر را بگیرد. با اولویت
منفی، آخرین گزینه است.
**نحوه تست:**
```bash
ddev exec php bin/console debug:router | grep landing
for s in نرم-افزار-مدیریت-کلینیک-زیبایی crm-کلینیک; do
curl -sk -o /dev/null -w "%{http_code} $s\n" "https://clinic-pro.ddev.site/$s"
done
curl -sk -o /dev/null -w "%{http_code} اسلاگ-ناموجود\n" https://clinic-pro.ddev.site/اسلاگ-ناموجود # باید 404
curl -sk -o /dev/null -w "%{http_code} /admin\n" https://clinic-pro.ddev.site/admin # باید 200
curl -sk -o /dev/null -w "%{http_code} /robots.txt\n" https://clinic-pro.ddev.site/robots.txt # باید 200
```
سه خط آخر مهم‌ترین‌اند: اگر روت لندینگ حریص باشد، پنل ادمین را می‌خورد.
### ۵. sitemap و لینک داخلی
`SeoController::sitemap()` را طوری تغییر بده که `LandingRegistry` را تزریق بگیرد و
هر هفت لندینگ را با `priority: 0.8` اضافه کند. اسلاگ فارسی باید در XML
**percent-encode** شود، وگرنه XML نامعتبر است:
```php
$loc = $base . '/' . rawurlencode($page->slug);
```
در فوتر (`_footer.html.twig`) یک ستون «راهکارها» با لینک به هر هفت صفحه اضافه کن.
بدون این، لندینگ‌ها فقط از sitemap دیده می‌شوند و از داخل سایت لینک نمی‌گیرند.
**نحوه تست:**
```bash
curl -sk https://clinic-pro.ddev.site/sitemap.xml | python3 -c "
import sys, xml.etree.ElementTree as ET
root = ET.fromstring(sys.stdin.read())
ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
locs = [u.find('s:loc', ns).text for u in root.findall('s:url', ns)]
print(len(locs), 'url'); [print(' ', l) for l in locs]
assert len(locs) == 8, 'باید هشت آدرس باشد'
"
```
### ۶. تست خودکار محتوای صفحات
`tests/Shared/LandingPageTest.php` با `ApiTestCase` یا `WebTestCase`:
- هر هفت اسلاگ → ۲۰۰
- `title` و `h1` و `canonical` هر صفحه یکتا و متعلق به خودش است
- اسلاگ ناموجود → ۴۰۴
- `/` هنوز ۲۰۰ می‌دهد و `h1` قدیمی‌اش را دارد
- `sitemap.xml` هشت `<loc>` دارد
**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/LandingPageTest.php`
## نکات مهم
- **متن هر صفحه باید واقعاً متفاوت باشد.** هفت صفحه با یک متن و فقط عوض‌شدن کلمهٔ
«زیبایی/دندان‌پزشکی/فیزیوتراپی» از نگاه گوگل محتوای تکراری است و هر هفت‌تا را
پایین می‌کشد. برای هر صنف، دردِ خودش را بنویس: کلینیک زیبایی → دوره‌های چندجلسه‌ای
و مصرف کالا؛ دندان‌پزشکی → پروندهٔ دندان و بیمهٔ تکمیلی؛ فیزیوتراپی → جلسات
درمانی و نوبت تکراری؛ درمانگاه → چند پزشک و چند بخش و منابع مشترک؛ مطب → سادگی و
یک‌نفره بودن؛ CRM → پیگیری بیمار و یادآوری و بازگشت مراجع.
- **صفحهٔ «رایگان» باید صادق باشد.** پلن `free` واقعاً در محصول هست
(`SubscriptionPlan` با نام `free`)، پس ادعای رایگان درست است — ولی در همان صفحه
صریح بنویس چه چیزی در پلن رایگان هست و چه چیزی نیست. وعدهٔ نادرست، هم نرخ تبدیل
را خراب می‌کند هم اعتماد را.
- **تم را کپی نکن، مشترک کن.** اگر بعد از این کار، تغییر رنگ دکمه در صفحهٔ اصلی
به‌طور خودکار در هر هفت لندینگ دیده نشد، یعنی وظیفهٔ ۲ و ۳ درست انجام نشده‌اند.
- **الگو: Registry + Value Object.** دلیل انتخاب: هفت نمونهٔ هم‌شکل که فقط داده‌شان
فرق دارد. این «abstraction برای آینده» نیست؛ همین حالا هفت مصرف‌کننده دارد.
- روت `/{slug}` با اسلاگ فارسی کار می‌کند ولی در لاگ و ابزارها percent-encoded دیده
می‌شود. این عادی است و نباید «درست» شود.
- این تغییر backend عمومی است و API ندارد، پس `docs/api/` دست نمی‌خورد. در عوض یک
یادداشت کوتاه در `README.MD` یا `docs/` بنویس که لندینگ‌ها کجا تعریف می‌شوند —
وگرنه نفر بعدی دنبال فایل Twig هر صفحه می‌گردد.
- `nobat724_front` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیک‌پرو
هستند.
@@ -0,0 +1,270 @@
# گِیتِ مجوز برای ServiceCatalogController
## پروژه
`clinicpro` (backend + پنل ادمین). cross-repo نیست — بررسی شد، هیچ‌کدام از دو کلاینت
دیگر این اندپوینت‌ها را مصرف نمی‌کنند.
## زمینه
بعد از یکی‌سازیِ مجوزها در `PermissionCatalog`، یک عبورِ سیستماتیک روی همهٔ routeها با
منشیِ واقعی و **همهٔ مجوزها خاموش** انجام شد. ۱۰ منبع از ۱۰ منبع درست ۴۰۳ دادند، ولی
`GET /api/v1/service-categories/tree` با همهٔ مجوزها خاموش هم `200` برگرداند.
علتش این است که `ServiceCatalogController` روی کلاسش فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
دارد و **هیچ‌کدام از ۱۵ routeاش** گِیت مجوز ندارند.
## مشکل / هدف
هر کاربرِ لاگین‌کرده‌ای که محیطِ فعال دارد — از جمله منشی با `services` کاملاً خاموش —
می‌تواند در **محیط خودش** دستهٔ سرویس بسازد، ویرایش و حذف کند، گروه بسازد، اقلام گروه
را جایگزین کند و روابط و override‌های شعبه‌ای سرویس را بازنویسی کند.
### دامنهٔ دقیق ریسک — این IDOR نیست
مالکیتِ محیط **کاملاً enforce شده** است. هر route از `requireCategory` / `requireGroup` /
`requireItem` رد می‌شود و همه به `owned()` می‌رسند:
```php
private function owned(User $user, ?object $entity, string $message): object
{
[$entityType, $entityId] = $this->branches->pair($user);
if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
}
return $entity;
}
```
پس دادهٔ هیچ محیطی به محیط دیگر نشت نمی‌کند. مسئله **بالا رفتن سطح دسترسی داخل همان
محیط** است: منشی‌ای که مالک صریحاً `services` را برایش خاموش کرده، همچنان کاتالوگ سرویس
همان کلینیک را می‌نویسد. شدت: **متوسط**، نه بحرانی.
### ⚠ دلیلی که قبلاً برای دست‌نزدن گفته شد، غلط بود
در گزارش قبلی نوشته شد «endpointهایش در جریانِ ثبت نوبت مصرف می‌شوند و بستنشان
نوبت‌دهی منشی را می‌شکند». این ادعا **بررسی نشده بود و رد شد**:
۱. `service-item/{uuid}/groups`، `item-group/*`، `service-item/{uuid}/relations` و
`service-selection/validate` در **هیچ‌کدام** از سه کلاینت مصرف‌کننده ندارند. تنها
ارجاعشان hookهای بدون مصرف‌کننده در `assets/admin/hooks/useServiceCatalog.ts` است
(`useServiceGroups`، `useServiceRelations`، `useSelectionPreview` — هیچ کامپوننتی
صداشان نمی‌زند). در `nobat724_front` و `clinic-pro-tauri` هم صفر ارجاع.
۲. مهم‌تر: کنترلرِ خواهرِ همین دامنه، `ClinicServiceController`، **همین حالا** هر
خواندنِ سرویس را پشت `services.view` بسته است — از جمله
`GET /api/v1/service-items/{sectionUuid}` که مودالِ ثبت نوبت از آن سرویس می‌خواند.
یعنی هر جریانی که به سرویس نیاز دارد **از قبل** به `services.view` نیاز دارد.
بستنِ کاتالوگ چیز تازه‌ای نمی‌شکند.
پس مسیر درست همان گِیتِ ساده است، نه استثنا و نه fallback.
### تنها مصرف‌کنندگانِ واقعی
| hook | صفحه/کامپوننت | route |
|---|---|---|
| `useCatalogCategories` | `CatalogCategoriesPage` (`/admin/service-categories`) | tree، create/update/delete دسته |
| `useCategoryIncludes` | `CatalogCategoriesPage` + `ServiceCategoryTab` | includes: list/add/remove |
| `useCatalogCategories` | `ServiceCategoryTab` داخل `ServiceDetailPage` | tree |
هر دو صفحه از قبل پشت `permission={['services','view']}` در `App.tsx` هستند. یعنی
گِیتِ فرانت هست و فقط گِیتِ سمت API غایب است.
---
## معیار پذیرش
- ✅ موفق: منشی با `services.view = true``GET /api/v1/service-categories/tree` جواب
`200` و همان درخت قبلی. صفحهٔ `/admin/service-categories` مثل امروز کار می‌کند.
- ✅ موفق: منشی با `services.update = true``PATCH /api/v1/service-category/{uuid}`
جواب `200`.
- ❌ خطا: منشی با `services` کاملاً خاموش → **هر ۱۵ route** جواب `403` با
`ERR_FORBIDDEN_001`. امروز `tree` جواب `200` می‌دهد؛ همین تفاوتْ تستِ اصلی است.
- ❌ خطا: منشی با `services.view = true` ولی `services.create = false`
`POST /api/v1/service-category` جواب `403`، در حالی که `tree` همچنان `200`.
- ⚠️ مرزی: پزشکِ عضو کلینیک با `services.update = false` → نوشتن‌ها `403`، خواندن‌ها
`200` (پیش‌فرضِ نقشش `view: true` است).
- ⚠️ مرزی: مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین بدون تغییر عبور می‌کنند —
`denyUnlessGranted` فقط نقشِ خودش را محدود می‌کند.
- ⚠️ مرزی: مودالِ ثبت نوبت برای منشیِ دارای `services.view` نباید عوض شود. این را
واقعاً باز کن و ببین، نه از روی کد حدس بزن.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/ClinicService/Controller/ServiceCatalogController.php` | ۱۵ routeِ بدون گِیت |
| `src/ClinicService/Controller/ClinicServiceController.php` | **الگوی مرجع**`denyServices()` |
| `assets/admin/pages/CatalogCategoriesPage.tsx` | `canUpdate` واحد برای هر سه دکمه |
| `assets/admin/components/ServiceCategoryTab.tsx` | prop به نام `canEdit` |
| `assets/admin/pages/ServiceDetailPage.tsx` | `canEdit={canUpdate}` را پاس می‌دهد |
| `docs/api/clinic-services.md` | سند اندپوینت‌ها |
| `docs/api/secretary.md` | جدول enforcement — سطر `services` |
---
## وضعیت فعلی
### کلاس بدون هیچ گِیتی جز احراز هویت
```php
#[OA\Tag(name: 'Treatment')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ServiceCatalogController extends BaseController
```
### نمونهٔ نوشتنِ باز — ساخت دسته
```php
#[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])]
public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
// ← هیچ denyUnlessGranted‌ای اینجا نیست
```
### الگوی مرجع در کنترلرِ خواهر — همین دامنه
```php
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود می‌کند). */
private function denyServices(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}
```
و در عمل per-action صدا زده می‌شود: ۵ بار `view`، ۲ بار `create`، ۲ بار `update`.
### فرانت — یک توگل برای هر سه عمل
```tsx
// CatalogCategoriesPage.tsx:33
const canUpdate = can('services', 'update');
// همین یکی هم دکمهٔ «افزودن» را کنترل می‌کند، هم ویرایش، هم حذف
```
---
## وظایف
### ۱. گِیتِ per-action روی هر ۱۵ route
`denyServices()` را عیناً مثل `ClinicServiceController` به `ServiceCatalogController`
اضافه کن (همان دو checker از constructor تزریق می‌شوند) و اولین خط هر action صدایش بزن.
نگاشت — هر ۱۵ تا، بدون استثنا:
| Action | Route | مجوز |
|---|---|---|
| `tree` | `GET /service-categories/tree` | `view` |
| `listIncludes` | `GET /service-category/{uuid}/includes` | `view` |
| `listGroups` | `GET /service-item/{uuid}/groups` | `view` |
| `validateSelection` | `POST /service-selection/validate` | `view` |
| `createCategory` | `POST /service-category` | `create` |
| `addInclude` | `POST /service-category/{uuid}/includes` | `create` |
| `createGroup` | `POST /service-item/{uuid}/groups` | `create` |
| `updateCategory` | `PATCH /service-category/{uuid}` | `update` |
| `updateGroup` | `PATCH /item-group/{uuid}` | `update` |
| `replaceGroupItems` | `PUT /item-group/{uuid}/items` | `update` |
| `replaceRelations` | `PUT /service-item/{uuid}/relations` | `update` |
| `replaceBranchOverrides` | `PUT /service-item/{uuid}/branch-overrides` | `update` |
| `deleteCategory` | `DELETE /service-category/{uuid}` | `delete` |
| `removeInclude` | `DELETE /service-category/{uuid}/includes/{childUuid}` | `delete` |
| `deleteGroup` | `DELETE /item-group/{uuid}` | `delete` |
`validateSelection` عمداً `view` است نه `create`: چیزی نمی‌سازد و فقط یک انتخاب را
اعتبارسنجی می‌کند؛ POST بودنش به‌خاطر حجمِ بدنه است، نه اثرِ جانبی.
**گِیت قبل از هر کار دیگری بیاید** — قبل از `json_decode`، قبل از `requireCategory`.
وگرنه ترتیبِ خطاها ۴۰۴/۴۲۲ را جای ۴۰۳ برمی‌گرداند و همان oracle‌ای می‌شود که گِیت
قرار بود ببندد.
**نحوه تست:** منشیِ تست `0912000209` / `QaTest@1234`، محیط کلینیک
`c3f1feac-4f56-45da-a3c4-b2182f4d6902`.
```bash
TOK=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"0912000209","password":"QaTest@1234"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
curl -sk -X POST https://clinic-pro.ddev.site/api/v1/auth/switch-context \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"db_uuid":"c3f1feac-4f56-45da-a3c4-b2182f4d6902","db_type":"clinic"}'
curl -sk -o /dev/null -w "%{http_code}\n" \
https://clinic-pro.ddev.site/api/v1/service-categories/tree -H "Authorization: Bearer $TOK"
```
> ⚠ **قبل از دست‌زدن به مجوزها backup بگیر و در پایان بایت‌به‌بایت برگردان:**
> ```bash
> ddev mysql -N -e "SELECT id, permission FROM doctor_secretaries WHERE secretary_id=15;" > /tmp/bk.tsv
> ```
> و **توجه:** `JSON_SET` روی مسیرِ تودرتویی که والدش وجود ندارد بی‌صدا کاری نمی‌کند.
> برای خاموش‌کردن حتماً کلِ آبجکت را بنویس:
> ```sql
> JSON_SET(permission,'$.resources.services',
> JSON_OBJECT('view',false,'create',false,'update',false,'delete',false))
> ```
> و یادت باشد **حذفِ کلید یعنی «پیش‌فرضِ نقش را بگیر»، نه «ممنوع»** — چون
> `getPermissions()` با رجیستری merge می‌کند.
### ۲. هم‌ترازیِ فرانت با گِیتِ per-action
الان یک `canUpdate` هر سه دکمه را کنترل می‌کند. با گِیتِ per-action، منشیِ دارای
`update` ولی بدون `create` دکمهٔ «افزودن» را می‌بیند و ۴۰۳ می‌گیرد.
در `CatalogCategoriesPage.tsx` و `ServiceCategoryTab.tsx`:
```tsx
const canCreate = can('services', 'create');
const canUpdate = can('services', 'update');
const canDelete = can('services', 'delete');
```
و هر دکمه به مجوزِ خودش وصل شود. `ServiceCategoryTab` به‌جای `canEdit: boolean` باید
هر سه را بگیرد؛ `ServiceDetailPage` هم مقادیر درست را پاس بدهد.
**نحوه تست:** vitest برای `CatalogCategoriesPage` — با `can` که فقط `update` را `true`
برمی‌گرداند، دکمهٔ افزودن نباید رندر شود ولی دکمهٔ ویرایش باید.
### ۳. تست backend
فایل تازه `tests/ClinicService/ServiceCatalogPermissionTest.php`، هم‌سبک با
`tests/Secretary/SecretaryResourceEnforcementTest.php`:
- منشی با `services` خاموش → هر ۱۵ route جواب `403`. **حلقه روی فهرست routeها بزن**،
نه سه‌تا نمونه؛ همین تست است که جلوی routeِ تازهٔ بی‌گِیت را در آینده می‌گیرد.
- `view` روشن → خواندن‌ها `200`، نوشتن‌ها همچنان `403`.
- مالکِ کلینیک با همان مجوزهای خاموش → همه‌چیز `200`.
### ۴. مستندات
- `docs/api/clinic-services.md`: برای هر ۱۵ route ستون Permission اضافه شود.
- `docs/api/secretary.md`: در جدول enforcement، سطر `services` باید
`ServiceCatalogController` را هم نام ببرد و هشدارِ «گِیت ندارد» حذف شود.
---
## نکات مهم
- **الگو تازه نساز.** `denyServices()` عیناً از `ClinicServiceController` کپی شود؛ همان
دامنه، همان دو checker، همان معنا. trait مشترک هم لازم نیست — دو متدِ چهارخطی در یک
دامنه، abstraction نمی‌خواهد.
- **مالکیتِ محیط از قبل درست است؛ دست نزن.** `owned()` و `requireItem()` کارشان را
می‌کنند. این تسک فقط لایهٔ مجوز را اضافه می‌کند، نه لایهٔ tenant.
- `denyUnlessGranted` برای نقش‌های غیرمنشی/غیرعضو `true` است، پس مالک و ادمین خودبه‌خود
عبور می‌کنند. «مالک هرگز نباید بتواند خودش را قفل کند» باید برقرار بماند.
- **hookهای بی‌مصرف را در همین تسک حذف نکن.** `useServiceGroups`، `useServiceRelations`
و `useSelectionPreview` مصرف‌کننده ندارند، ولی حذفشان کارِ این پرامپت نیست و ریسکِ
بی‌دلیل اضافه می‌کند. فقط در گزارش ذکرشان کن.
- بعد از تغییر، هر دو سوییت کامل سبز بمانند: `ddev exec php bin/phpunit` و
`npx vitest run`. خط پایه: ۱۵۲۸ تست backend و ۷۹۵ تست frontend.
- `AppointmentEditPage.serviceMode` زیر بار موازی flaky است؛ اگر افتاد تنها اجرایش کن
و اگر سبز شد به این تغییر ربطی ندارد.
+324
View File
@@ -0,0 +1,324 @@
# پاک‌سازی نوبت‌دهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایم‌لاین یکپارچه
## پروژه
`clinicpro` (بک‌اند Symfony + پنل ادمین React).
یک وظیفه **cross-repo** است و علامت‌گذاری شده: حذف شعبه به `nobat724_front` می‌رسد
(`nobat724_front/services/response.js:165` اندپوینت `doctor-address/{id}` را صدا می‌زند).
## زمینه
مدل Resource-First پیاده شده است: منبع، سرویس، گزینهٔ سرویس، دستهٔ سراسری. حالا مالک
محصول می‌خواهد لایه‌هایی که در این مدل مصرف‌کننده ندارند برداشته شوند (شعبه/اتاق، گروه‌های
انتخاب، تب بخش‌های نوبت)، تعطیلات یک بار سراسری تعریف شود، و تنظیمات نوبت‌دهی منابع
هم‌شکل پزشکان شود.
## مشکل / هدف
پنج تغییر مستقل، به همین ترتیب:
1. **حذف شعبه و اتاق** از محصول — بدون تغییر منطق نوبت‌دهی.
2. **تعطیلات سراسری**: مدیر سیستم تعطیلات رسمی سال را ثبت کند؛ هر محیط بتواند
غیرفعالشان کند؛ پزشک و منبع تعطیلی اختصاصی خودشان را داشته باشند.
3. **تب منابع** در `/admin/settings/appointment-settings`، هم‌شکل تب پزشک.
4. **حذف تب‌های «گروه‌ها و آیتم‌ها» و «بخش‌های نوبت»** از صفحهٔ سرویس.
5. **تایم‌لاین یکپارچه**: پزشکانِ سرویسی و منابعِ قابل‌رزرو در یک نما، با ظرفیت و وقت آزاد.
---
## ⚠️ نقد پرامپت — قبل از شروع بخوان
خواستهٔ «همه‌چیز شعبه حذف شود، شامل `DoctorAddress`» با «منطق نوبت‌دهی بدون تغییر بماند»
قابل جمع نیست. شواهد از خود کد و دیتابیس:
| شاهد | یعنی |
|---|---|
| `appointments.address_id`**۷۵ از ۷۵ نوبت مقدار دارد** | آدرس، محلِ خودِ نوبت است نه یک بخش تنظیمات |
| `nobat724_front/services/response.js:165``api/v1/clinic-pro/doctor-address/{id}` | سایت عمومی روی همین قرارداد رزرو می‌گیرد |
| `ClinicResource::__construct()``assignTenantPair($address->tenantEntityType(), …)` | جفت محیط هر منبع از آدرس مشتق می‌شود |
| `price_lists.address_id` · `resource_pools.address_id` · `service_branch_overrides.address_id` | سه زیرسیستم دیگر هم به آن گره خورده‌اند |
پس **`DoctorAddress` در این پرامپت حذف نمی‌شود**؛ به یک لنگرِ نامرئی تنزل می‌کند: هیچ
صفحه، منو یا مفهومی به کاربر نشان نمی‌دهد، ولی جدولش سر جایش می‌ماند. آنچه واقعاً حذف
می‌شود، دامنهٔ `Branch` است (اتاق، ساعت کاری شعبه، صفحه‌ها، اندپوینت‌ها).
حذف کامل `DoctorAddress` یک پرامپت جداست و بازنویسی جریان رزرو در **دو ریپو** را می‌خواهد؛
قبل از شروع باید مالک محصول هزینه‌اش را ببیند. اگر پس از دیدن این ارقام باز هم حذف کامل
خواسته شد، همان‌جا توقف کن و تکلیف را بپرس — با این پرامپت انجامش نده.
---
## معیار پذیرش
### قابلیت ۱ — حذف شعبه و اتاق
- ✅ موفق: `/admin/branches` و زیرصفحه‌هایش ۴۰۴ می‌دهند، آیتم «شعبه‌ها و اتاق‌ها» از منوی
تنظیمات رفته، و `ddev exec php bin/phpunit` کامل سبز است — یعنی جستجوی آزاد و رزرو
دقیقاً همان نتایج قبلی را می‌دهد.
- ❌ خطا: `GET /api/v1/branches`**۴۰۴** (روت وجود ندارد)، نه ۵۰۰.
- ⚠️ مرزی: منبعی که `subject_kind = 'room'` دارد باید همچنان کار کند — اتاق به‌عنوان
*منبع* می‌ماند، فقط موجودیت `Room` می‌رود.
### قابلیت ۲ — تعطیلات سراسری
- ✅ موفق: با توکن `ROLE_ADMIN`، `POST /api/v1/admin/national-holidays` یک تعطیل می‌سازد و
همان روز بلافاصله در `GET /api/v1/resource/{uuid}/availability` با دلیل
`national_holiday` خالی برمی‌گردد.
- ❌ خطا: همان `POST` با توکن پزشک → **۴۰۳**.
- ⚠️ مرزی: محیطی که `TenantHolidayOverride(is_working = true)` دارد، همان روز **باز**
است و ساعتش برمی‌گردد.
### قابلیت ۳ — تب منابع در تنظیمات نوبت‌دهی
- ✅ موفق: در `/admin/settings/appointment-settings` تب «منابع» ساعت کاری هفتگی، تاریخ‌های
خاص و تعطیلات هر منبع را می‌دهد و ذخیره‌اش در `GET /api/v1/resource/{uuid}/calendar`
دیده می‌شود.
- ❌ خطا: منشیِ بدون مجوز `appointment_settings.update` فیلدها را read-only می‌بیند و
`PUT` سرور ۴۰۳ می‌دهد.
- ⚠️ مرزی: کلینیکِ بدون هیچ منبعی، حالت خالی با لینک «تنظیمات ← منابع» نشان دهد، نه صفحهٔ سفید.
### قابلیت ۴ — حذف تب‌های سرویس
- ✅ موفق: `/admin/service/{uuid}` پنج تب دارد (اطلاعات، تعرفه‌ها، بیمه‌ها، کالاها،
دسته‌بندی‌ها، لاگ) و هیچ ورودی به گروه‌ها و بخش‌های نوبت ندارد.
- ❌ خطا: باز کردن مستقیم `?tab=segments` به تب اطلاعات برگردد، نه خطای رندر.
- ⚠️ مرزی: سرویسی که همین حالا `SegmentTemplate` دارد باید **دقیقاً مثل قبل** رزرو شود —
تست‌های موجود `tests/Appointment` سبز بمانند.
### قابلیت ۵ — تایم‌لاین یکپارچه
- ✅ موفق: نمای «زمانبندی» هم ردیف پزشکانِ سرویسی و هم ردیف منابعِ قابل‌رزرو را نشان دهد،
با بازهٔ اشغال و وقت آزاد و ظرفیت هر ردیف.
- ❌ خطا: روزی که هیچ ردیفی داده ندارد، پیام خالیِ صریح بدهد نه اسکلتِ همیشگی.
- ⚠️ مرزی: منبعی با `capacity = 3` و دو نوبت هم‌زمان، «۱ ظرفیت آزاد» نشان دهد نه «پر».
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Branch/` | کل دامنه: `BranchController`, `Room`, `BranchWorkingHours`, `RoomService`, `WorkingHoursService`, `BranchResolver` |
| `src/Resource/Service/ResourceAvailabilityService.php` | تنها مصرف‌کنندهٔ `BranchWorkingHoursRepository` بیرون از `src/Branch` |
| `src/Resource/Entity/ClinicResource.php` | `subject_kind='room'` و `ResourceLinker` به `Room` وصل‌اند |
| `src/Resource/Controller/HolidayController.php` | `GET /national-holidays` + `POST/DELETE /holiday-overrides`**POST برای national ندارد** |
| `src/Resource/Entity/NationalHoliday.php` · `TenantHolidayOverride.php` | مدل تعطیلات، از قبل درست است |
| `src/Appointment/Controller/AppointmentSettingsController.php` | تعطیلی اختصاصی پزشک (`Holiday`) |
| `assets/admin/pages/HolidaysSettingsPage.tsx` | صفحهٔ `/admin/holidays` |
| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | تب‌بندی per پزشک با `.seg` |
| `assets/admin/pages/ServiceDetailPage.tsx` | `TABS` — گروه‌ها و بخش‌های نوبت اینجاست |
| `assets/admin/pages/AppointmentsPage.tsx` · `components/appointments/ResourceTimeline.tsx` · `TurnsTimeline.tsx` | سه نمای فعلی |
| `assets/admin/components/resources/ResourceWorkingHoursPanel.tsx` · `ResourceExceptionsPanel.tsx` | پنل‌های آمادهٔ منبع — در تب جدید همین‌ها مصرف می‌شوند |
## وضعیت فعلی
`ResourceAvailabilityService` ساعت واقعی منبع را از تقاطع با ساعت شعبه می‌سازد:
```php
// src/Resource/Service/ResourceAvailabilityService.php
private readonly BranchWorkingHoursRepository $branchHours,
$branchByDay = $this->branchHoursByDay($resource);
if ($branchByDay !== null) {
$branchWindows = $branchByDay[$dayOfWeek] ?? [];
if ($branchWindows === []) {
// روز، بدون ساعت شعبه یعنی بسته
```
`HolidayController` فقط خواندن تعطیلات ملی را دارد؛ هیچ مسیری برای ساختنشان نیست:
```php
#[Route('/api/v1/national-holidays', name: 'national_holidays_list', methods: ['GET'])]
#[Route('/api/v1/holiday-overrides', name: 'holiday_override_create', methods: ['POST'])]
#[Route('/api/v1/holiday-override/{uuid}', name: 'holiday_override_delete', methods: ['DELETE'])]
```
`ServiceDetailPage` هفت تب دارد:
```tsx
const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'tariffs', label: 'تعرفه‌ها' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'groups', label: 'گروه‌ها و آیتم‌ها' },
{ id: 'segments', label: 'بخش‌های نوبت' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'goods', label: 'کالاهای مرتبط' },
{ id: 'history', label: 'لاگ تغییرات' },
] as const;
```
---
## وظایف
### ۱. حذف دامنهٔ شعبه و اتاق
**دامنهٔ حذف:** `src/Branch/` کامل، سه صفحهٔ پنل (`BranchesPage`, `BranchRoomsPage`,
`BranchWorkingHoursPage`)، آیتم `branches` در `settingsMenu.ts`، و روت‌هایشان در `App.tsx`.
**دامنهٔ نگه‌داشتن:** `DoctorAddress` (لنگر محیط و محل نوبت — بالا را بخوان).
قبل از حذف، مهاجرت منابعِ نوع اتاق:
```php
// ClinicResource.subject_kind === 'room' امروز به rooms.id اشاره می‌کند.
// یک migration، آن ردیف‌ها را به منبع بی‌subject تبدیل می‌کند (نامشان می‌ماند):
UPDATE clinic_resources SET subject_kind = NULL, room_id = NULL WHERE subject_kind = 'room';
```
سپس لایهٔ شعبه از موتور دسترس‌پذیری برداشته می‌شود. **این تنها جای منطق است که واقعاً
تغییر می‌کند**، پس صریح بنویسش:
```php
// ResourceAvailabilityService: تزریق BranchWorkingHoursRepository حذف، و
// $branchByDay همه‌جا null می‌شود → لایهٔ «ساعت شعبه» از کسر بیرون می‌رود.
// دلیل معماری: با حذف شعبه، تنها مرجع ساعت کاری، شیفت خودِ منبع است.
```
دلیلِ `outside_branch_hours` و `branch_closed` و `branch_inactive` از
`REASON_LABELS` فرانت هم برداشته شوند (`ResourceExceptionsPanel.tsx`).
**نحوه تست:**
```bash
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/phpunit # همه سبز — مخصوصاً tests/Appointment و tests/Resource
ddev exec php bin/console debug:router | grep -c "branch\|room" # باید 0 باشد
npx vitest run # خط پایه: ۱۰۰ فایل / ۶۶۰ تست
```
و یک رزرو واقعی از مسیر عمومی بگیر (`POST /api/v1/appointment-availability` سپس
hold → confirm) تا ثابت شود همان اسلات‌های قبلی برمی‌گردند.
**cross-repo:** بعد از حذف، در `nobat724_front` دنبال `doctor-address` بگرد و گزارش بده
کدام صفحه‌ها مصرفش می‌کنند. اگر اندپوینت عمومی `clinic-pro/doctor-address/{id}` را دست
نزدی (نباید بزنی)، سایت نمی‌شکند — همین را صریح در گزارش بنویس.
---
### ۲. تعطیلات سراسری، سه لایه
مدل از قبل درست است و ساخته نمی‌شود؛ فقط سه چیزِ کم اضافه می‌شود.
**الف) CRUD مدیر سیستم روی `NationalHoliday`:**
```php
// src/Resource/Controller/HolidayController.php
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/national-holidays', methods: ['POST'])] // {jalali_date, title}
#[Route('/api/v1/admin/national-holiday/{uuid}', methods: ['PATCH','DELETE'])]
```
`jalali_date` ورودی است و `date` (نیمه‌شب تهران) و `jalali_year` از آن مشتق می‌شوند —
مسئولیت تبدیل در یک Service بماند، نه Controller.
**ب) نمایش تعطیلات سراسری در تب تعطیلاتِ پزشک و منبع:** هر دو تب علاوه بر تعطیلی
اختصاصی، فهرست تعطیلات ملی سال را **فقط‌خواندنی** با یک سوییچ «این روز باز هستیم» نشان
دهند؛ سوییچ همان `POST /api/v1/holiday-overrides` موجود را صدا بزند.
**ج) `/admin/holidays`** جای مدیریت سراسری هر محیط بماند (همین حالا هست) و در توضیح
صفحه بنویسد که این‌ها پیش‌فرضِ همهٔ پزشکان و منابع‌اند.
**نحوه تست:**
```bash
# ✅ ادمین می‌سازد
curl -X POST .../api/v1/admin/national-holidays -H "Authorization: Bearer $ADMIN" \
-d '{"jalali_date":"1405-01-13","title":"سیزده‌بدر"}'
# ✅ همان روز در دسترس‌پذیری منبع خالی است، با دلیل national_holiday
curl ".../api/v1/resource/$R/availability?from=…&to=…" -H "Authorization: Bearer $DOC"
# ❌ پزشک نمی‌سازد → 403
# ⚠️ بعد از POST /holiday-overrides با is_working=true همان روز باز می‌شود
```
تست PHPUnit: `tests/Resource/NationalHolidayEndpointTest.php` با هر سه سناریو.
---
### ۳. تب «منابع» در تنظیمات نوبت‌دهی کلینیک
در `ClinicAppointmentSettingsPage` یک سطح تب بالاتر اضافه کن: **پزشکان | منابع**. سطح
دوم برای منابع همان الگوی فعلی است (یک `.seg` با نام هر منبع).
کامپوننت جدید لازم نیست — پنل‌ها ساخته شده‌اند:
```tsx
{scope === 'resources' && selectedResource && (
<div key={selectedResource}>
<ResourceWorkingHoursPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
<ResourceExceptionsPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
</div>
)}
```
`.seg` نکته دارد: کلاس فعالش `on` است (`active` هم alias شده) — تب بدون آن هیچ نشانه‌ای
ندارد.
**نحوه تست:** `npx vitest run assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx`
با سه تست: تب منابع فهرست منابع را می‌دهد · انتخاب منبع پنل ساعت کاری را می‌آورد ·
کلینیک بدون منبع حالت خالی می‌دهد. سپس اسکرین‌شات:
`node .claude/skills/redesign-page/driver.mjs variants "https://clinic-pro.ddev.site/admin/settings/appointment-settings"`
---
### ۴. حذف دو تب از صفحهٔ سرویس
فقط **UI** حذف می‌شود: دو ورودی از `TABS` و رندرشان، به‌علاوهٔ کامپوننت‌های
`ServiceGroupsTab` و `ServiceSegmentsTab` و تست‌هایشان.
`SegmentTemplate`، `ServiceSelectionGroup`، `ServiceItemRelation` و اندپوینت‌هایشان
**می‌مانند**: `AppointmentPlanBuilder` ورودی‌اش همین‌هاست و سرویسی که امروز اتاق و دستگاه
را با هم می‌گیرد، بدونشان می‌شکند. سرویسِ بدون template هم از قبل با `singleSegment()`
رزرو می‌شود، پس حذف تب هیچ رفتاری را عوض نمی‌کند.
`tab` در URL می‌نشیند؛ مقدار ناشناخته باید به `info` برگردد نه اینکه چیزی رندر نشود.
**نحوه تست:** `npx vitest run assets/admin/pages/ServiceDetailPage.test.tsx` +
`ddev exec php bin/phpunit tests/Appointment` (باید بدون تغییر سبز بماند) + باز کردن
`/admin/service/{uuid}?tab=segments` و دیدن تب اطلاعات.
---
### ۵. تایم‌لاین یکپارچه
سه نمای فعلی (`table` · `timeline` · `resources`) به دو نما می‌رسند: **جدولی** و
**زمانبندی**. نمای زمانبندی دو گروه ردیف دارد:
```
پزشکان ← پزشکانی که حالت نوبت‌دهی‌شان service است
منابع ← منابعی که برای سرویسی قابل رزروند (ResourceServiceOffering فعال دارند)
```
هر ردیف باید سه چیز بدهد: بازهٔ اشغال، وقت آزاد، و ظرفیت. برای منبع، «آزاد» یعنی
`capacity` منهای تعداد اشغال هم‌پوشان در آن لحظه — نه صفر و یک؛ منبعِ ظرفیت‌۳ با دو نوبت
هم‌زمان هنوز یک جا دارد.
سمت سرور، `GET /api/v1/resources/timeline` موجود را توسعه بده (ساخت اندپوینت جدید ممنوع
است تا وقتی این کافی است): فیلتر `only_bookable=1` و فیلد `free_slots` به هر ردیف اضافه
شود. ردیف پزشک از همان `slots` فعلی می‌آید و در فرانت با ردیف منابع در یک نما ادغام
می‌شود.
**نحوه تست:** تست PHPUnit برای `free_slots` روی منبع ظرفیت‌۳ با دو اشغال هم‌پوشان
(انتظار: ۱`npx vitest run assets/admin/components/appointments/`؛ و اسکرین‌شات نمای
زمانبندی در تاریخ `2026-08-05` که دادهٔ واقعی دارد.
---
## نکات مهم
- **ترتیب اجرا اجباری است.** قابلیت ۱ موتور دسترس‌پذیری را تغییر می‌دهد؛ اگر بعد از
قابلیت ۵ انجام شود، تایم‌لاین را می‌شکند و علتش پیدا نیست.
- **خط پایهٔ تست را اول بگیر:** `ddev exec php bin/phpunit` و `npx vitest run`
(۱۰۰ فایل / ۶۶۰ تست، همه سبز در ۲۰۲۶-۰۸-۰۲). هر شکستی بعد از این، مالِ همین کار است.
- **مسیر اسلاتی دست نخورد.** تست‌های `--group=slot-mode-frozen` نگهبانند؛ حتی یک شکست
یعنی توقف.
- **`vitest` داخل ddev اجرا نمی‌شود** — روی هاست با `npx vitest run`.
- **دو صفحه، یک منوی تنظیمات:** `settingsMenu.ts` منبع واحد سایدبار دسکتاپ و فهرست
موبایل است. حذف آیتم شعبه فقط همان‌جا انجام شود.
- **مستندات همین جلسه:** `docs/api/branch.md` حذف، `docs/api/resource.md` و
`docs/api/resource-calendar.md` و `docs/api/clinic-services.md` به‌روز، و
`docs/architecture/resource-first-model.md` باید بگوید شعبه از مدل بیرون رفته.
- **الگو:** برداشتن لایهٔ شعبه از `ResourceAvailabilityService` را به‌صورت حذف یک لایه از
زنجیرهٔ کسر انجام بده (همان ساختار فعلی)، نه با `if` تازه — کلاس باید کوچک‌تر شود نه شاخه‌دارتر.
- **دادهٔ تست:** `ddev exec php bin/console app:seed-scenarios --reset -n` سه سناریو را
می‌سازد؛ کاربرها در `TEST_USERS.md`. کاربر `0912000301` رمز ندارد — از `0912000201`
استفاده کن.
+623
View File
@@ -0,0 +1,623 @@
# حساب کاربری برای پرسنل — نقش `ROLE_STAFF`، ورود به پنل، داشبورد اختصاصی و مشاهدهٔ سرویس‌های تخصیص‌یافته
## پروژه
`clinicpro` (بک‌اند Symfony + پنل ادمین React). cross-repo نیست؛ `nobat724_front` تغییری ندارد.
## زمینه
امروز پرسنل (`clinic_staff`) فقط یک «رکورد اطلاعاتی» است: کلینیک یا پزشک از
`/admin/staff` یک ردیف با نام/تلفن/سمت/کد ملی می‌سازد و همان ردیف در جاهای دیگر
به‌عنوان «مجری سرویس» انتخاب می‌شود:
- `ServiceItem::$staffMembers` (جدول `service_item_staff`) — پرسنل تخصیص‌یافته به هر سرویس
- `Appointment::$staff` (`appointments.staff_id`) — پرسنل نوبت
- `SessionService::$staff` — پرسنل مجری سرویس در جلسهٔ بیمار
اما `ClinicStaff` هیچ ارتباطی با `users` ندارد، پس پرسنل نه می‌تواند لاگین کند و نه
داشبوردی دارد. الگوی مشابهی که در پروژه **کار می‌کند** «منشی» است: منشی یک `User`
است با `ROLE_SECRETARY` که از طریق ردیف `DoctorSecretary` به مالک (پزشک/کلینیک) وصل
می‌شود (`SecretaryService::resolveSecretaryUser`). همین الگو باید برای پرسنل تکرار شود.
## مشکل / هدف
وقتی کلینیک یا پزشک در `/admin/staff` پرسنل اضافه می‌کند، اگر شمارهٔ موبایل بدهد و
گزینهٔ «ایجاد حساب کاربری» را بزند:
1. یک `User` با نقش `ROLE_STAFF` ساخته/به‌روزرسانی شود و به همان ردیف `ClinicStaff` وصل شود.
2. آن کاربر بتواند با موبایل/رمز در `/admin/login` وارد شود.
3. بعد از ورود، `primary_role = 'staff'` بگیرد و محیط کاری‌اش همان مطب/کلینیکِ مالک باشد.
4. داشبورد اختصاصی «پرسنل» ببیند: سرویس‌هایی که به او تخصیص داده شده + نوبت‌های خودش.
5. **به هیچ چیز دیگری دسترسی نداشته باشد** — نه لیست بیماران، نه سرویس‌های کل کلینیک،
نه مالی، نه مدیریت پرسنل.
### تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود
بند ۵ سخت‌ترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل می‌سازد:
- اکثر کنترلرها فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]` دارند و tenant را از
`EntityContextResolver` می‌گیرند.
- `SecretaryAccessChecker::denyUnlessGranted` و `ClinicDoctorAccessChecker` برای
کاربری که منشی/پزشکِ مهمان **نیست** عملاً no-op هستند (فقط نقش خودشان را می‌سنجند).
- پس به‌محض اینکه `EntityContextResolver` برای کاربر staff محیط کلینیک را resolve کند،
`GET /api/v1/service-items` **همهٔ** سرویس‌های کلینیک را برمی‌گرداند، `/api/v1/patients`
همهٔ بیماران را، و…
بنابراین طراحی این تسک **default-deny** است: یک `StaffRouteGuardSubscriber` روی رویداد
`kernel.controller` که برای کاربرِ «فقط staff» هر مسیر خارج از allowlist را ۴۰۳ می‌کند.
دلیل انتخاب Subscriber به‌جای افزودن `denyUnlessGranted` به ده‌ها کنترلر: تک‌نقطه‌ای
بودن تصمیم (اگر فردا کنترلر جدیدی اضافه شود، به‌صورت پیش‌فرض بسته است، نه باز).
## معیار پذیرش
- ✅ موفق:
- `POST /api/v1/staff` با `{"full_name":"زهرا احمدی","phone":"09121110000","has_account":true,"password":"Staff@1234"}`
توسط توکن کلینیک → `201` و در بدنه `has_account: true` و `user_uuid` غیرتهی؛ در DB
یک `users` با `roles` شامل `ROLE_STAFF` و `clinic_staff.user_id` پرشده.
- `POST /api/v1/user/login` با همان موبایل/رمز → `200` و `access_token`.
- `GET /oauth/userinfo` با آن توکن → `primary_role: "staff"` و در `available_contexts`
یک آیتم با `role: "staff"` و `db_uuid` برابر uuid کلینیک/پزشکِ مالک و
`permissions.resources` فقط شامل `{"services":{"view":true},"appointments":{"view":true}}`.
- `GET /api/v1/dashboard/staff``200` با `stats.today_appointments`، `services` (فقط
سرویس‌هایی که این پرسنل در `service_item_staff` آن‌هاست) و `today_appointments`.
- در پنل: ورود با آن کاربر → ریدایرکت به `/admin/dashboard` و نمایش «داشبورد پرسنل»؛
سایدبار فقط «داشبورد» و «سرویس‌های من» را دارد.
- ❌ خطا:
- `GET /api/v1/service-items` با توکن پرسنل → `403` با `ERR_FORBIDDEN_001` (نه ۲۰۰ با
سرویس‌های کلینیک). همین‌طور `/api/v1/staff` (GET/POST)، `/api/v1/patients`،
`/api/v1/appointments`، `/api/v1/dashboard/clinic`.
- `POST /api/v1/staff` با `has_account: true` و `phone` خالی یا نامعتبر →
`422` با `ERR_STAFF_MOBILE_INVALID`.
- ورود پرسنلِ `active=false``/oauth/userinfo` هیچ context با `role: "staff"` ندارد و
`GET /api/v1/dashboard/staff``403`.
- ⚠️ مرزی:
- موبایلی که **از قبل** `User` دارد (مثلاً بیمار یا منشی): کاربر جدید ساخته نشود؛
فقط `ROLE_STAFF` به نقش‌هایش اضافه شود و ردیف پرسنل به همان کاربر وصل شود. اگر آن
کاربر هم منشی است و هم پرسنل → `primary_role` باید `secretary` بماند (نقش قوی‌تر) و
context مربوط به staff هم در `available_contexts` بیاید.
- یک نفر پرسنلِ **دو** کلینیک: دو ردیف `clinic_staff` با یک `user_id` → دو context در
لیست؛ بعد از `switch-context` داشبورد داده‌های همان کلینیک را بدهد.
- همان موبایل دوباره در همان کلینیک ثبت شود → `409` با `ERR_STAFF_MOBILE_TAKEN`
(نه ساخت ردیف تکراری).
- پرسنل بدون هیچ سرویس تخصیص‌یافته → `services: []` و پیام خالی در UI، نه ۵۰۰.
- موبایل مالک (خودِ پزشک/کلینیک) به‌عنوان پرسنل → `422` با `ERR_STAFF_MOBILE_INVALID`
و پیام «شماره مالک نمی‌تواند پرسنل باشد» (جلوگیری از تنزل نقش/سردرگمی context).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Staff/Entity/ClinicStaff.php` | افزودن رابطهٔ `user` |
| `src/Staff/Repository/ClinicStaffRepository.php` | کوئری‌های `findActiveByUser`، `findActiveByUserAndEntity`، `findByEntityAndPhone` |
| `src/Staff/Service/StaffAccountService.php` | **جدید** — ساخت/اتصال/قطع حساب کاربری پرسنل |
| `src/Staff/Controller/StaffController.php` | پذیرش `has_account`/`password` در create/update |
| `src/Staff/Controller/StaffDashboardController.php` یا `src/Dashboard/Controller/DashboardController.php` | اندپوینت داشبورد پرسنل |
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | **جدید** — default-deny برای کاربر staff |
| `src/Auth/Entity/User.php` | `isStaff()` باید `ROLE_STAFF` را هم بپذیرد |
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` + `buildAvailableContexts()` |
| `src/Shared/Context/EntityContextResolver.php` | resolve محیط برای کاربر staff |
| `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید |
| `src/ClinicService/Repository/ServiceItemRepository.php` | `findByStaff(ClinicStaff)` |
| `assets/admin/pages/StaffPage.tsx` | فیلد موبایل/حساب کاربری + ستون «حساب» |
| `assets/admin/pages/DashboardPage.tsx` | `StaffDashboard` + dispatcher |
| `assets/admin/pages/StaffMyServicesPage.tsx` | **جدید** — صفحهٔ «سرویس‌های من» |
| `assets/admin/App.tsx` | `ALLOWED_ROLES` + روت‌های نقش staff |
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش staff |
| `assets/admin/types/index.ts` | فیلدهای جدید `ClinicStaff` |
| `docs/api/staff.md`، `docs/api/auth.md`، `docs/api/dashboard.md` | مستندسازی (قانون ثابت پروژه) |
## وضعیت فعلی
### `src/Staff/Entity/ClinicStaff.php` — هیچ ارتباطی با `User` ندارد
```php
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
#[ORM\Table(name: 'clinic_staff')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
class ClinicStaff
{
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
private string $fullName;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $phone = null;
// …
}
```
### `src/Auth/Entity/User.php:121` — گیت ورود به پنل
```php
public function isStaff(): bool
{
return $this->hasRole('ROLE_DOCTOR')
|| $this->hasRole('ROLE_CLINIC')
|| $this->hasRole('ROLE_SECRETARY')
|| $this->hasRole('ROLE_ADMIN')
|| $this->hasRole('ROLE_REPRESENTATION')
|| $this->hasRole('ROLE_IMPORTER');
}
```
`PasswordAuthenticator::onAuthenticationSuccess:79` بدون این متد لاگین را ۴۰۳ می‌کند:
```php
if (!$user->isStaff()) {
return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
}
```
### `src/Auth/Controller/AuthController.php:690` — نقش اصلی و لیست محیط‌ها
```php
private function resolvePrimaryRole(User $user): string
{
$roles = $user->getRoles();
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
return 'user';
}
```
و در `buildAvailableContexts()` منشی این‌طور context می‌گیرد (الگوی مرجع برای پرسنل):
```php
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
// …
$contexts[] = [
'type' => 'doctor',
'db_uuid' => $rel->getDoctor()->getUuid(),
'name' => 'مطب ' . $rel->getDoctor()->getName(),
'role' => 'secretary',
'scope' => 'doctor',
'permissions' => $rel->getPermissions(),
];
}
```
### `src/Secretary/Service/SecretaryService.php:38` — الگوی مرجع ساخت کاربر
```php
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
{
$user = $this->userRepo->findByMobile($mobile);
if ($user === null) {
$user = new User($mobile);
if (!empty($password)) {
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
}
}
if (!empty($name)) {
$user->setRealName(trim($name));
}
$roles = $user->getRoles();
if (!in_array('ROLE_SECRETARY', $roles, true)) {
$roles[] = 'ROLE_SECRETARY';
$user->setRoles(array_values(array_unique($roles)));
}
$this->userRepo->save($user);
return $user;
}
```
### `src/ClinicService/Entity/ServiceItem.php:44` — رابطهٔ سرویس ↔ پرسنل (منبع «سرویس‌های من»)
```php
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'service_item_staff')]
private Collection $staffMembers;
```
### `assets/admin/App.tsx:83` — نقش‌های مجاز پنل
```tsx
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
```
### `assets/admin/hooks/usePermissions.ts` — نکتهٔ حیاتی
```ts
const perms = context?.permissions as { resources?: ... } | undefined | null;
if (!perms?.resources) return true; // نبودِ permissions یعنی «آزاد»، نه «بسته»
```
پس context پرسنل **حتماً** باید `permissions.resources` صریح داشته باشد، وگرنه UI همه‌چیز
را باز می‌کند.
## وظایف
### ۱. مدل داده: اتصال `ClinicStaff` به `User`
`src/Staff/Entity/ClinicStaff.php`:
```php
#[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user = null;
public function getUser(): ?User { return $this->user; }
public function hasAccount(): bool { return $this->user !== null; }
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
```
و در `toArray()`:
```php
'has_account' => $this->user !== null,
'user_uuid' => $this->user?->getUuid(),
```
ایندکس لازم: `#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]`
(چون `findActiveByUser` در هر بار `userinfo` صدا زده می‌شود).
`ClinicStaffRepository`:
```php
/** @return ClinicStaff[] ردیف‌های فعالِ این کاربر در همهٔ محیط‌ها */
public function findActiveByUser(User $user): array;
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff;
/** برای جلوگیری از ثبت تکراری یک موبایل در همان محیط */
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff;
```
سپس migration:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` باید سبز باشد؛
`DESCRIBE clinic_staff` ستون `user_id` را نشان دهد.
### ۲. `StaffAccountService` — تنها نقطهٔ ساخت/اتصال حساب پرسنل
`src/Staff/Service/StaffAccountService.php` (جدید). قرینهٔ `SecretaryService::resolveSecretaryUser`
است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمی‌تواند پرسنل خودش باشد»:
```php
class StaffAccountService
{
public function __construct(
private readonly UserRepository $userRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly SmsService $smsService,
private readonly string $appUrl,
) {}
/**
* حساب کاربری پرسنل را می‌سازد یا به کاربر موجود وصل می‌کند و ROLE_STAFF می‌دهد.
*
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
*/
public function attachAccount(ClinicStaff $staff, string $mobile, ?string $password, User $owner): User
{
$mobile = $this->normalizeMobile($mobile); // ارقام فارسی → لاتین
if (!preg_match('/^09\d{9}$/', $mobile)) {
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422);
}
if ($mobile === $owner->getMobileNumber()) {
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, 'شماره مالک نمی‌تواند پرسنل باشد', 422);
}
$user = $this->userRepo->findByMobile($mobile);
// یک موبایل، در یک محیط، فقط یک ردیف پرسنل
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409);
}
if ($user === null) {
$user = new User($mobile);
}
if (!empty($password)) {
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
}
$user->setRealName($staff->getFullName());
$user->addRole('ROLE_STAFF');
$this->userRepo->save($user);
$staff->setUser($user)->setPhone($mobile);
$this->staffRepo->save($staff);
$this->sendWelcomeSms($mobile, $ownerName); // TAG_STAFF، مشابه TAG_SECRETARY
return $user;
}
/** قطع دسترسی بدون حذف ردیف پرسنل (سوابق سرویس/نوبت حفظ می‌شود). */
public function detachAccount(ClinicStaff $staff): void;
}
```
نکته‌ها:
- `addRole()` روی `User` از قبل هست (`src/Auth/Entity/User.php:107`) — از آن استفاده کن،
آرایهٔ roles را دستی دستکاری نکن.
- برای SMS: `SmsLog::TAG_STAFF` را به ثابت‌ها و `SmsMessageTemplate` اضافه کن (الگوی
`SmsLog::TAG_SECRETARY => [...]` در `src/Sms/Entity/SmsMessageTemplate.php:58`). اگر
افزودن قالب پیامک ریسک/هزینه دارد، همان `TAG_SECRETARY` را استفاده نکن — به‌جایش
ارسال SMS را در این فاز حذف کن و در پاسخ API فقط `has_account` را برگردان.
- کدهای خطای جدید در `src/Shared/Constant/ErrorCodes.php`:
`ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست'`،
`ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است'`.
**نحوه تست:** یونیت‌تست `tests/Staff/StaffAccountServiceTest.php` با سه سناریو:
کاربر جدید ساخته می‌شود / کاربر موجود فقط نقش می‌گیرد و رمز قبلی‌اش پاک نمی‌شود اگر
`password` خالی باشد / موبایل مالک → `AppException` با کد ۴۲۲.
### ۳. `StaffController` — پذیرش حساب کاربری در create/update
در `create()` و `update()` (فایل `src/Staff/Controller/StaffController.php`) بعد از
`$this->staffRepo->save($staff)`:
```php
$wantsAccount = (bool) ($data['has_account'] ?? false);
if ($wantsAccount) {
$this->staffAccounts->attachAccount($staff, (string) ($data['phone'] ?? ''), $data['password'] ?? null, $user);
} elseif ($staff->hasAccount() && array_key_exists('has_account', $data)) {
$this->staffAccounts->detachAccount($staff);
}
return $this->success($staff->toArray(), 201);
```
کنترلر نازک بماند: هیچ منطق hash/نقش/اعتبارسنجی موبایل داخل کنترلر نوشته نشود
(`AppException` را `ExceptionSubscriber` به envelope خطا تبدیل می‌کند).
**مهم:** `resolveEntity()` همین کنترلر نباید برای `ROLE_STAFF` چیزی برگرداند — امروز به
`['unknown', null]` می‌افتد و ۴۰۳ می‌دهد؛ همین رفتار درست است، دست نخورد (پرسنل حق
مدیریت پرسنل ندارد).
**نحوه تست:**
```bash
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"09390039833","password":"09390039833"}' | jq -r .access_token)
curl -s -X POST https://clinic-pro.ddev.site/api/v1/staff \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"full_name":"زهرا احمدی","phone":"09121110000","job_title":"پرستار","has_account":true,"password":"Staff@1234"}' | jq
# انتظار: 201، has_account:true، user_uuid غیرتهی
```
### ۴. Auth — نقش، محیط کاری و مجوزهای پرسنل
الف) `src/Auth/Entity/User.php``isStaff()` را با `|| $this->hasRole('ROLE_STAFF')` کامل کن
(بدون آن، لاگین پرسنل ۴۰۳ می‌گیرد).
ب) `AuthController::resolvePrimaryRole()` — بعد از `secretary` و قبل از `representation`:
```php
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
```
(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی می‌ماند چون نقش پرتوان‌تر است.)
ج) `AuthController::buildAvailableContexts()` — بلوک جدید در انتها:
```php
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
$owner = $row->getEntityType() === 'clinic'
? $this->clinicRepo->find($row->getEntityId())
: $this->doctorRepo->find($row->getEntityId());
if ($owner === null) { continue; }
$contexts[] = [
'type' => $row->getEntityType(),
'db_uuid' => $owner->getUuid(),
'name' => $row->getEntityType() === 'clinic' ? ($owner->getName() ?? '') : 'مطب ' . $owner->getName(),
'role' => 'staff',
'scope' => $row->getEntityType(),
'permissions' => StaffPermissions::DEFAULT, // ثابت، نه قابل ویرایش در این فاز
];
}
```
با ثابتِ صریح (مثلاً `src/Staff/Security/StaffPermissions.php`):
```php
public const DEFAULT = [
'version' => 1,
'resources' => [
'services' => ['view' => true],
'appointments' => ['view' => true],
],
];
```
د) `EntityContextResolver` — تا وقتی staff در `canActInClinic()` / `canActForDoctor()`
شناخته نشود، `fromActiveContext()` برای او `null` برمی‌گرداند و داشبورد ۴۰۳ می‌دهد:
```php
// canActInClinic()
if ($this->staffRepo->findActiveByUserAndEntity($user, 'clinic', $clinic->getId()) !== null) {
return true;
}
// canActForDoctor()
if ($this->staffRepo->findActiveByUserAndEntity($user, 'doctor', $doctor->getId()) !== null) {
return true;
}
```
`fromRole()` برای staff هیچ fallback ندهد (مثل منشی) — محیطش فقط از `UserActiveContext`
می‌آید، چون می‌تواند پرسنل چند محیط باشد.
**نحوه تست:**
```bash
STAFF=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"09121110000","password":"Staff@1234"}' | jq -r .access_token)
curl -s https://clinic-pro.ddev.site/oauth/userinfo -H "Authorization: Bearer $STAFF" | jq '.data.primary_role, .data.available_contexts'
# انتظار: "staff" و یک context با role=staff و permissions محدود
```
### ۵. Default-deny: `StaffRouteGuardSubscriber`
`src/Staff/Security/StaffRouteGuardSubscriber.php` (جدید) روی `KernelEvents::CONTROLLER`:
```php
/**
* کاربری که «فقط» ROLE_STAFF دارد به هیچ اندپوینتی جز allowlist دسترسی ندارد.
*
* چرایی: بیشتر کنترلرها tenant را از EntityContextResolver می‌گیرند و مجوز را فقط
* برای منشی/پزشکِ مهمان می‌سنجند؛ بدون این گارد، کاربر staff با context حل‌شده به
* دادهٔ کل کلینیک می‌رسد. تصمیم در یک نقطه متمرکز است تا کنترلرِ جدید هم به‌صورت
* پیش‌فرض بسته باشد.
*/
private const ALLOWED_PREFIXES = [
'/api/v1/dashboard/staff',
'/api/v1/staff/me',
'/api/v1/auth/switch-context',
'/api/v1/user/change-password',
'/oauth/',
];
```
قواعد:
- فقط وقتی فعال شود که کاربر `ROLE_STAFF` دارد و **هیچ‌کدام** از
`ROLE_ADMIN/ROLE_CLINIC/ROLE_DOCTOR/ROLE_SECRETARY/ROLE_REPRESENTATION` را ندارد.
- در غیر allowlist: `AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403)`.
- مسیرهای عمومی (غیر `/api`) دست‌نخورده بمانند.
**نحوه تست:** `tests/Staff/StaffRouteGuardTest.php` — با توکن پرسنل روی این‌ها ۴۰۳:
`/api/v1/service-items`، `/api/v1/staff`، `/api/v1/patients`، `/api/v1/appointments`،
`/api/v1/dashboard/clinic`؛ و روی `/api/v1/dashboard/staff` و `/oauth/userinfo` ۲۰۰.
### ۶. اندپوینت داشبورد پرسنل
`GET /api/v1/dashboard/staff` — قرینهٔ `/api/v1/dashboard/secretary`
(`src/Dashboard/Controller/DashboardController.php:529`). طبق قاعدهٔ «اول بگرد، بعد بساز»:
اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.
```php
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
#[IsGranted('ROLE_STAFF')]
public function staff(#[CurrentUser] User $user): JsonResponse
{
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
}
[$entityType, $entityId] = $context->toEntityPair();
$row = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
if ($row === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
}
return $this->success([
'scope' => $entityType,
'staff' => ['uuid' => $row->getUuid(), 'full_name' => $row->getFullName(), 'job_title' => $row->getJobTitle()],
'owner' => ['name' => $ownerName],
'stats' => ['today_appointments' => $todayCount, 'services' => count($services)],
'services' => $services, // از ServiceItemRepository::findByStaff()
'today_appointments' => $todayAppointments, // appointments.staff_id = این پرسنل، امروز
]);
}
```
`ServiceItemRepository::findByStaff(ClinicStaff $staff): array` — DQL با
`INNER JOIN i.staffMembers s WHERE s = :staff AND i.active = true`، محدود به همان tenant.
خروجی سرویس‌ها فقط فیلدهای لازم: `uuid, name, price_rials, duration_minutes, section_name, active`
(قیمت لازم است چون پرسنل باید بداند چه سرویسی با چه تعرفه‌ای به او تخصیص یافته).
نوبت‌های امروز: DQL روی `Appointment` با `a.staff = :staff` و بازهٔ
`strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` (تایم‌استمپ صحیح، نه DateTime).
**نحوه تست:** بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویس‌ها:
```bash
curl -s https://clinic-pro.ddev.site/api/v1/dashboard/staff -H "Authorization: Bearer $STAFF" | jq '.data.services, .data.stats'
```
### ۷. پنل: فرم پرسنل + نقش staff در روتینگ و سایدبار
الف) `assets/admin/pages/StaffPage.tsx`:
- در `schema` فیلدهای `has_account: z.boolean().optional()` و `password: z.string().optional()`
اضافه شود؛ با `superRefine`: اگر `has_account` روشن است، `phone` باید `^09\d{9}$` باشد.
- در `StaffFormFields` یک چک‌باکس «ایجاد حساب کاربری برای ورود به پنل» و ورودی رمز
(فقط وقتی چک‌باکس روشن است). ورودی موبایل همان `phone` فعلی است با `numericField(..., 11)`.
- یک ستون جدید در `columns`: «حساب کاربری» با `ActiveBadge`/متن «دارد / ندارد» از
`s.has_account`.
- `assets/admin/types/index.ts``ClinicStaff` با `has_account: boolean; user_uuid: string | null`.
ب) `assets/admin/App.tsx`:
```tsx
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;
```
و روت جدید داخل `AdminLayout`:
```tsx
<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
```
ج) `assets/admin/components/layout/Sidebar.tsx` — بلوک `if (primaryRole === "staff")`
قبل از `representation`، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با
«سرویس‌های من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. `ROLE_LABELS` هم مقدار
`staff: 'پرسنل'` بگیرد.
د) `assets/admin/pages/DashboardPage.tsx` — کامپوننت `StaffDashboard` قرینهٔ
`SecretaryDashboard` (همان `LoadingSkeleton`، همان کارت‌های KPI، همان حالت خطا) و در
dispatcher: `if (primaryRole === 'staff') return <StaffDashboard />;`
ه) `assets/admin/pages/StaffMyServicesPage.tsx` — جدول سرویس‌های تخصیص‌یافته با
`DataTable` + `PageHeader` (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز
می‌شود، `backTo="/admin/dashboard"` بدهد.
**نحوه تست:**
```bash
ddev exec npx tsc --noEmit --project tsconfig.json
ddev exec yarn dev
ddev exec yarn test
```
سپس دستی: ورود با `09121110000 / Staff@1234` در `/admin/login`
داشبورد پرسنل، سایدبار دو آیتمی، ورود مستقیم به `/admin/patients` → ریدایرکت به داشبورد.
### ۸. تست‌ها و مستندات
- `tests/Staff/StaffAccountServiceTest.php` — یونیت (وظیفهٔ ۲).
- `tests/Staff/StaffRouteGuardTest.php` — فانکشنال default-deny (وظیفهٔ ۵).
- `tests/Staff/StaffDashboardTest.php` — موفق (۲۰۰ با سرویس‌های خودش) / خطا (پرسنل
غیرفعال → ۴۰۳) / مرزی (بدون سرویس → `services: []`).
- `TenantSchemaCoverageTest` باید همچنان سبز باشد (`clinic_staff` از قبل tenant-keyed است؛
ستون `user_id` طبقه‌بندی آن را عوض نمی‌کند — اگر تست قرمز شد، دلیلش را بررسی کن، نه
اینکه entity را به `GlobalTables` اضافه کنی).
- اجرای کامل: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
- مستندات (قانون ثابت پروژه): `docs/api/staff.md` (فیلدهای جدید create/update + اندپوینت
`/api/v1/staff/me` اگر ساخته شد)، `docs/api/auth.md` (نقش `staff` در `primary_role` و
context جدید)، `docs/api/dashboard.md` (اندپوینت `/api/v1/dashboard/staff`).
## نکات مهم
- **پرسنل ≠ منشی.** منشی مجوزهای قابل‌ویرایش دارد (`DoctorSecretary.permission`)؛ پرسنل در
این فاز مجوز ثابت و حداقلی دارد (`StaffPermissions::DEFAULT`). ویرایشگر مجوز پرسنل
ساخته نشود — abstraction «برای آینده» ممنوع است.
- **`usePermissions` نبودِ `permissions` را «آزاد» تفسیر می‌کند** — context پرسنل حتماً
آبجکت صریح `resources` داشته باشد، وگرنه UI همه‌چیز را باز می‌کند.
- **غیرفعال‌سازی پرسنل باید دسترسی را قطع کند:** `PATCH /api/v1/staff/{uuid}/toggle` وقتی
`active=false` می‌شود، `findActiveByUser` دیگر آن ردیف را برنمی‌گرداند، پس context حذف
می‌شود. اما توکن JWT قبلی تا انقضا معتبر است؛ به همین دلیل گارد وظیفهٔ ۶ (بررسی
`findActiveByUserAndEntity` در هر درخواست داشبورد) لازم است و نمی‌توان فقط به context
اکتفا کرد.
- **حذف نشدن سوابق:** `detachAccount` فقط `user_id` را `null` می‌کند؛ ردیف `clinic_staff` و
ارجاعات `service_item_staff` / `appointments.staff_id` / `session_services.staff_id` دست
نمی‌خورند.
- **تایم‌استمپ‌ها `int` Unix** و تاریخ‌ها در UI شمسی با `formatDate` — طبق قواعد پروژه.
- **الگو:** `StaffAccountService` نقش Service Layer را دارد (قرینهٔ `SecretaryService`) و
`StaffRouteGuardSubscriber` الگوی Guard/Interceptor است؛ انتخابشان برای تک‌نقطه‌ای کردن
دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است».
- **رشته‌های UI فارسی** بمانند و صفحات جدید از همان `PageHeader` / `DataTable` /
`SettingsLayout` و توکن‌های `styles.css` استفاده کنند — طراحی جدید ساخته نشود.
+2
View File
@@ -4,6 +4,8 @@ description: وقتی کاربر یک لینک figma.com/design با node-id م
کن، نیازهای فرانت‌اند و بک‌اند را استخراج کن و پس از تأیید پیاده‌سازی کن.
---
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
## بخش ۰ — زبان (قبل از هر کاری)
- ورودی من فارسی است. منظور را استخراج کن، نه ترجمه‌ی لغوی.
- متن را به یک normalized English spec تبدیل کن با فیلدهای:
+1 -1
View File
@@ -3,7 +3,7 @@ name: prompt-writer
description: تولید فایل پرامپت .md برای یک قابلیت یا باگ‌فیکس در پروژه ClinicPro. استفاده کن وقتی کاربر می‌گوید «یک پرامپت بنویس»، «پرامپت بساز»، «write a prompt»، «برام پرامپت بنویس برای X». این skill پروژه را تحلیل می‌کند، سپس یک فایل .md کامل در .claude/prompt/ می‌سازد و دستور اجرا را نمایش می‌دهد.
---
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، معیار پذیرش، تست، مستندات، SOLID، رفتار تحلیل‌گر) را تعیین می‌کند؛ این skill فقط قواعد stack و مسیرها و قالب خروجی را دارد. در تناقض، guidelines برنده است.
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، معیار پذیرش، تست، مستندات، SOLID، رفتار تحلیل‌گر) را تعیین می‌کند؛ این skill فقط قواعد stack و مسیرها و قالب خروجی را دارد. در تناقض، guidelines برنده است.
## نحوه دریافت ورودی
+2
View File
@@ -3,6 +3,8 @@ name: qa-clinicpro
description: تست QA اپلیکیشن ClinicPro مثل یک کاربر واقعی — ابتدا ساخت همهٔ نقش‌ها و پروفایل‌های کامل (پزشک مستقل، پزشک عضو کلینیک، کلینیک، منشی، نماینده، بیمار، …) و تعیین ماتریس سطح دسترسی، سپس تست ماتریس دسترسی با تک‌تک آن‌ها. هر مانعی سر راه تست را مثل یک دولوپر ارشد Symfony/React خودش رفع می‌کند و تست را ادامه می‌دهد. اجرای اپ، ورود با هر نقش، پیمایش صفحات پنل ادمین، اسکرین‌شات، کشف خطاهای کنسول و شبکه، تست UI/UX و RTL، تست دسترسی نقش‌ها (authz)، تست قرارداد API و اندازه‌گیری کارایی، و تولید Bug Report. Use when asked to QA, test, smoke-test, find bugs in, screenshot, or verify ClinicPro's admin panel or API — «تست کن»، «باگ پیدا کن»، «QA کن»، «این صفحه را بررسی کن».
---
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
# QA ClinicPro
ClinicPro = بک‌اند Symfony 7.4 + یک **SPA کلاینت‌ساید React 19** که از `/admin/*` سرو می‌شود.
+19 -4
View File
@@ -33,12 +33,17 @@ const PORT = Number(process.env.CDP_PORT ?? 9444);
* provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
* until they are. TEST_USERS.md is stale — its accounts do not exist.
*/
// Re-verified 2026-08-07 by real logins: the DB was reseeded, so the old clinic
// and secretary numbers are gone and 09390039833 is now ROLE_CLINIC, not doctor.
// `staff` was seeded with its mobile as the password rather than QaTest@1234.
const ROLES = {
admin: ['09120671756', 'QaTest@1234'],
clinic: ['09127000000', 'QaTest@1234'],
secretary: ['09123456778', 'QaTest@1234'],
doctor: ['09390039833', 'QaTest@1234'],
clinic: ['09390039833', 'QaTest@1234'],
secretary: ['0912000109', 'QaTest@1234'],
doctor: ['0912000101', 'QaTest@1234'],
representation: ['09124000001', 'QaTest@1234'],
staff: ['09128726723', '09128726723'],
multirole: ['0912000201', 'QaTest@1234'],
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
@@ -227,9 +232,16 @@ async function withPage(url, opts, fn) {
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
version: 0,
};
// `--ui '{"darkMode":true,"density":"compact"}'` تم را پیش از اولین رندر می‌کارد.
const ui = opts.ui
? `localStorage.setItem('clinicpro-ui', ${JSON.stringify(
JSON.stringify({ state: JSON.parse(opts.ui), version: 0 }),
)});`
: '';
await S('Runtime.evaluate', {
expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed','1');`,
localStorage.setItem('pwa-dismissed','1');${ui}`,
});
// Errors before this point belong to the login page, not the page under test.
@@ -458,6 +470,9 @@ const opts = {
wait: Number(flag('wait', 4000)),
full: argv.includes('--full'),
body: flag('body', null),
// تم و چگالی در `localStorage['clinicpro-ui']` می‌نشینند و بدون seed کردنشان
// دارک‌مود و حالت فشرده اصلاً قابل اسکرین‌شات نیستند.
ui: flag('ui', null),
};
try {
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* guide-shots.mjs — تصویربردار راهنمای کاربری.
*
* برای هر نقش لاگین می‌کند، فهرست صفحاتش را می‌پیماید و از هرکدام یک PNG می‌گیرد.
* خروجی: <out>/<role>/<NN-slug>.png به‌همراه shots.json که ترتیب و عنوان‌ها را
* برای متن راهنما نگه می‌دارد.
*
* node guide-shots.mjs --role clinic-owner --out /tmp/guide
* node guide-shots.mjs --all --out /tmp/guide
*
* وابستگی ندارد: Node 22 با WebSocket سراسری مستقیم با CDP حرف می‌زند.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PASSWORD = process.env.GUIDE_PASSWORD ?? 'QaTest@1234';
const PORT = Number(process.env.CDP_PORT ?? 9500);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
/**
* پنج شخصیتِ راهنما. هر صفحه یک گام از داستانِ همان نقش است، پس ترتیب مهم است و
* الفبایی نیست: کاربر تازه‌وارد از داشبورد شروع می‌کند، نه از تنظیمات.
*/
const ROLES = {
'independent-doctor': {
title: 'پزشک مستقل',
mobile: '0912000101',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های امروز'],
['appointments/new', 'ثبت نوبت جدید'],
['patients', 'پرونده‌ها'],
['my-patients', 'بیماران من'],
['clinic-services', 'سرویس‌ها'],
['service-categories', 'دسته‌بندی سرویس‌ها'],
['insurance-pricing', 'بیمه و تعرفه'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['treatment-cases', 'دوره‌های درمان'],
['resources', 'منابع'],
['inventory', 'انبارداری'],
['my-secretaries', 'منشی‌ها'],
['staff', 'پرسنل'],
['settings-menu', 'تنظیمات'],
['appointment-settings', 'تنظیمات نوبت‌دهی'],
['holidays', 'تعطیلات'],
['record-number-settings', 'شمارهٔ پرونده'],
['tags-settings', 'برچسب‌ها'],
['sms-wallet', 'کیف پول پیامک'],
['subscription', 'اشتراک'],
['discounts', 'تخفیف‌ها'],
['profile', 'پروفایل پزشک'],
['account-settings', 'حساب کاربری'],
],
},
'clinic-owner-doctor': {
title: 'پزشکی که کلینیک دارد',
mobile: '0912000201',
// این حساب هم مطب شخصی دارد هم کلینیک، پس اپ اول «انتخاب محیط» می‌پرسد.
context: 'کلینیک',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های کلینیک'],
['my-clinic', 'کلینیک من'],
['settings/clinic-doctors', 'پزشکان کلینیک'],
['settings/appointment-settings', 'نوبت‌دهی کلینیک'],
['settings/practice-domain', 'حوزهٔ فعالیت'],
['clinic-services', 'سرویس‌های کلینیک'],
['resources', 'منابع'],
['resources/types', 'نوع منابع'],
['resources/pools', 'استخر منابع'],
['resources/skills', 'مهارت‌ها'],
['staff', 'پرسنل'],
['my-secretaries', 'منشی‌ها'],
['patients', 'پرونده‌ها'],
['treatment-cases', 'دوره‌های درمان'],
['my-payments', 'پرداخت‌ها'],
['my-financial', 'مدیریت پرداخت'],
['claims', 'مطالبات بیمه'],
['inventory', 'انبارداری'],
['settings-menu', 'تنظیمات'],
['subscription', 'اشتراک'],
],
},
'clinic-manager': {
title: 'مدیر کلینیک',
mobile: '0912000301',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های درمانگاه'],
['my-clinic', 'کلینیک من'],
['settings/clinic-doctors', 'پزشکان'],
['settings/appointment-settings', 'نوبت‌دهی'],
['clinic-services', 'سرویس‌ها'],
['service-categories', 'دسته‌بندی‌ها'],
['resources', 'منابع'],
['staff', 'پرسنل'],
['my-secretaries', 'منشی‌ها'],
['patients', 'پرونده‌ها'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['insurance-pricing', 'بیمه و تعرفه'],
['inventory', 'انبارداری'],
['treatment-cases', 'دوره‌های درمان'],
['settings-menu', 'تنظیمات'],
['sms-wallet', 'کیف پول پیامک'],
['subscription', 'اشتراک'],
],
},
secretary: {
title: 'منشی',
mobile: '0912000209',
pages: [
['appointments', 'نوبت‌ها'],
['appointments/new', 'ثبت نوبت'],
['appointments/reserve', 'نوبت رزرو'],
['patients', 'پرونده‌ها'],
['patients/new', 'تشکیل پرونده'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['treatment-cases', 'دوره‌های درمان'],
['secretary-earnings', 'درآمد من'],
['secretary-settlement', 'تسویه حساب'],
['account-settings', 'حساب کاربری'],
],
},
staff: {
title: 'پرسنل',
mobile: '09120002105',
pages: [
['my-sessions', 'جلسات امروز من'],
['account-settings', 'حساب کاربری'],
],
},
};
const args = process.argv.slice(2);
const argOf = (name, fallback = null) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? fallback : args[i + 1];
};
const OUT = argOf('out', '/tmp/guide');
const WIDTH = Number(argOf('w', 1440));
const HEIGHT = Number(argOf('h', 900));
const WAIT = Number(argOf('wait', 3500));
const wanted = args.includes('--all') ? Object.keys(ROLES) : [argOf('role')].filter(Boolean);
if (wanted.length === 0) {
console.error('استفاده: --role <name> یا --all');
console.error('نقش‌ها: ' + Object.keys(ROLES).join(', '));
process.exit(1);
}
async function login(mobile) {
const res = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: mobile, password: PASSWORD }),
});
const body = await res.json();
if (!body.access_token) {
throw new Error(`ورود ${mobile} ناموفق: ${JSON.stringify(body).slice(0, 160)}`);
}
return body;
}
async function cdpUrl() {
for (let i = 0; i < 50; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
return (await r.json()).webSocketDebuggerUrl;
} catch { await new Promise((r) => setTimeout(r, 300)); }
}
throw new Error('Chrome بالا نیامد');
}
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', '--force-device-scale-factor=2',
`--remote-debugging-port=${PORT}`, `--user-data-dir=/tmp/guide-shots-${process.pid}`,
`--window-size=${WIDTH},${HEIGHT}`, 'about:blank',
], { stdio: 'ignore' });
const ws = new WebSocket(await cdpUrl());
await new Promise((r) => ws.addEventListener('open', r, { once: true }));
let msgId = 0;
const pending = new Map();
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
});
const rpc = (method, params, sessionId) => new Promise((resolve) => {
const id = ++msgId;
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params, sessionId }));
});
const { targetId } = await rpc('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await rpc('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => rpc(m, p, sessionId);
await S('Page.enable');
await S('Runtime.enable');
const evalJs = async (expression) => {
const { result } = await S('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
return result?.value;
};
// اجرای تک‌نقشی نباید فهرست نقش‌های قبلی را پاک کند.
const manifestPath = join(OUT, 'shots.json');
const manifest = existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8'))
: {};
for (const roleKey of wanted) {
const role = ROLES[roleKey];
const dir = join(OUT, roleKey);
mkdirSync(dir, { recursive: true });
const { access_token, refresh_token } = await login(role.mobile);
// localStorage به origin وابسته است؛ اول باید روی همان دامنه باشیم.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
await evalJs(`localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify({
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, version: 0,
}))});
localStorage.setItem('pwa-dismissed','1');
// تور راهنما روی تصویرها می‌افتد و متنِ خودِ راهنما را می‌پوشاند.
localStorage.setItem('clinicpro-tours-suppress','1');`);
// حسابِ چندمحیطی تا محیطش را انتخاب نکند، هر مسیری به «انتخاب محیط» می‌رود.
if (role.context) {
await S('Page.navigate', { url: `${BASE}/admin/select-context` });
await new Promise((r) => setTimeout(r, 2500));
const picked = await evalJs(`(() => {
const btn = [...document.querySelectorAll('button')]
.find(b => b.textContent.includes(${JSON.stringify(role.context)}));
if (!btn) return false;
btn.click();
return btn.textContent.trim().slice(0, 60);
})()`);
await new Promise((r) => setTimeout(r, 2500));
console.log(` محیط انتخاب شد: ${picked || 'پیدا نشد ⚠'}`);
}
manifest[roleKey] = { title: role.title, mobile: role.mobile, shots: [] };
console.log(`\n### ${role.title} (${role.mobile})`);
for (const [index, [path, label]] of role.pages.entries()) {
const file = `${String(index + 1).padStart(2, '0')}-${path.replace(/\//g, '-')}.png`;
await S('Page.navigate', { url: `${BASE}/admin/${path}` });
await new Promise((r) => setTimeout(r, WAIT));
// تور خودکار روی صفحه می‌نشیند؛ بسته می‌شود تا تصویر خودِ صفحه را نشان دهد.
await evalJs(`(() => {
const close = document.querySelector('.driver-popover-close-btn');
if (close) close.click();
})()`);
await new Promise((r) => setTimeout(r, 400));
const landed = await evalJs('location.pathname');
const empty = await evalJs('(document.body.innerText || "").trim().length < 40');
const shot = await S('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(dir, file), Buffer.from(shot.data, 'base64'));
const note = landed.endsWith(path) ? '' : ` (منتقل شد به ${landed})`;
console.log(` ${empty ? '⚠' : '✓'} ${label}${file}${note}`);
manifest[roleKey].shots.push({ file, path, label, landed, empty });
}
}
mkdirSync(OUT, { recursive: true });
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`\nفهرست: ${manifestPath}`);
ws.close();
chrome.kill();
+135 -78
View File
@@ -1,165 +1,222 @@
---
name: redesign-page
description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرین‌شات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحراف‌ها از دیزاین‌سیستم، و بازنویسی صفحه با کامپوننت‌ها و توکن‌های موجود. استفاده کن وقتی کاربر یک URL از /admin می‌دهد و می‌گوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
description: نقد و بازطراحی حرفه‌ای UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرین‌شات در چهار نما (روشن، تیره، فشرده، موبایل)، پروبِ دسترسی‌پذیری روی DOM زنده، نگاشت URL به فایل سورس، آدیت انحراف از دیزاین‌سیستم، و بازنویسی با کامپوننت‌ها و توکن‌های موجود. استفاده کن وقتی کاربر یک URL از /admin می‌دهد و می‌گوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «این صفحه را نقد کن»، «redesign this page»، «UI/UX review»، «این قسمت را درست کن»، یا «screenshot این صفحه».
---
# بازطراحی صفحه پنل ادمین ClinicPro
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
# نقد و بازطراحی صفحهٔ پنل ادمین ClinicPro
نقش: متخصص ارشد UI/UX. هدف **بهبود تجربهٔ کاربری در چهارچوب تم فعلی** است، نه ساختن
هویت بصری جدید. هر تغییری که با دیزاین‌سیستم فعلی ناسازگار باشد، رد است.
پنل ادمین یک SPA کلاینت‌ساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
یعنی `curl` و فلگ `--screenshot` کروم به درد نمی‌خورند: هر دو روی فرم لاگین می‌نشینند،
چون توکن JWT در `localStorage['clinicpro-auth']` است.
درایور این skill آن کار را انجام می‌دهد: با API لاگین می‌کند، `localStorage` را seed
می‌کند، بعد ناوبری و اسکرین‌شات می‌گیرد — با CDP روی `WebSocket` نیتیو Node 22،
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
چون توکن JWT در `localStorage['clinicpro-auth']` است. درایور این skill آن کار را
می‌کند: با API لاگین می‌کند، `localStorage` را seed می‌کند، تم/تراکم را می‌نشاند، بعد
ناوبری و اسکرین‌شات می‌گیرد — با CDP روی `WebSocket` نیتیو Node 22، **بدون هیچ وابستگی
npm** (نه playwright، نه puppeteer).
مسیرها نسبت به `clinicpro/` هستند.
## پیش‌نیازها
هیچ نصبی لازم نیست. فقط این دو:
هیچ نصبی لازم نیست:
```bash
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
```
کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
کروم جای دیگری است؟ `CHROME_BIN` را ست کن.
## گردش کار
### ۱. اسکرین‌شات صفحه فعلی
### ۰. اول دیزاین‌سیستم را بخوان — قبل از هر چیز
**منبع حقیقتِ توکن‌ها `assets/admin/styles.css` است**، نه `docs/admin-ui/ui-design-spec.md`
(آن سند قدیمی است و پالت بنفشش با کد شیپ‌شده نمی‌خواند).
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
node .claude/skills/redesign-page/driver.mjs ds # توکن‌ها + کلاس‌ها + کامپوننت‌ها
node .claude/skills/redesign-page/driver.mjs ds tokens # فقط توکن‌ها
node .claude/skills/redesign-page/driver.mjs ds components # فقط کامپوننت‌های مشترک با Props
```
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
یعنی حدس زدن.
خروجی واقعی: `TOKENS (57)` و `SHARED COMPONENTS (26)`. قانون ترتیب:
**اول کامپوننت موجود، بعد توسعه/عمومی‌کردنش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
فلگ‌ها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلی‌ثانیه صبر برای رندر)،
`--full` (کل صفحه، نه فقط ویوپورت).
موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
### ۱. چهار نمای اجباری
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
node .claude/skills/redesign-page/driver.mjs variants \
"https://clinic-pro.ddev.site/admin/resources" --dir /tmp/clinicpro-review
```
### ۲. نگاشت URL به سورس + آدیت
چهار فایل می‌سازد: `-light` · `-dark` · `-compact` · `-mobile`. **هر چهار را با ابزار
Read باز کن و نگاه کن.** قضاوت با یک اسکرین‌شات یعنی صفحه‌ای که در سه نمای دیگر خراب است.
تم تیره و تراکم فشرده در این پنل تنظیمات واقعی کاربرند، نه فرض.
تک‌نما:
```bash
node .claude/skills/redesign-page/driver.mjs inspect \
"https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png \
--theme dark --density compact --w 390 --h 844 --full --wait 6000
```
فلگ‌ها: `--w/--h` ویوپورت · `--wait` میلی‌ثانیه · `--full` کل صفحه ·
`--theme light|dark` · `--density comfortable|compact` · `--context clinic|personal` ·
`--no-probe`.
هر `shot` یک **پروب رانتایم** هم می‌زند که فقط روی DOM رندرشده دیدنی است:
```
RUNTIME
⚠ 2 form field(s) with no label
```
چه چیزهایی می‌گیرد: سرریز افقی، دکمهٔ آیکونیِ بی‌نام (بدون `aria-label`/`title`
فیلد بدون لیبل، و کنترل کوتاه‌تر از ۳۲px (هدف لمسی ۴۴px است).
### ۲. نگاشت URL به سورس + آدیت ایستا
```bash
node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/resources"
```
خروجی واقعی:
```
route clinics/:uuid
component ClinicDetailPage
file assets/admin/pages/ClinicDetailPage.tsx
components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
lines 1035
route resources
component ResourcesPage
file assets/admin/pages/ResourcesPage.tsx
components PageHeader, DataTable, ResourceBlocksModal, ConfirmDialog, SearchableSelect, …
lines 278
test assets/admin/pages/ResourcesPage.test.tsx
AUDIT
assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
AUDIT clean
```
روی هر فایل دلخواه هم مستقیم:
روی هر فایل مستقیم:
```bash
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/ClinicDetailPage.tsx
# AUDIT
# assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
```
### ۳. قبل از نوشتن کد، دیزاین‌سیستم را بخوان
آدیت این‌ها را می‌گیرد: `<select>` نیتیو، `.btn` بدون واریانت، هگز هاردکد، overlay دستی،
`<label>` داخل `.field`، تاریخ میلادی، `type="date"`، دکمهٔ آیکونی بدون `aria-label`،
توکن تعریف‌نشده، و `.seg` بدون کلاس `on/active`.
**منبع حقیقتِ توکن‌ها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
(آن سند قدیمی و پالت بنفشش با کد شیپ‌شده نمی‌خواند).
### ۳. گزارش — همیشه با این ۹ بخش
```bash
sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکن‌ها
ls assets/admin/components/ui/ # کامپوننت‌های آماده
```
۱. تحلیل صفحه چه کاری برای چه کاربری؛ جریان اصلی
۲. مشکلات UI سلسله‌مراتب بصری، فاصله، تایپوگرافی، رنگ، انحراف از DS
۳. مشکلات UX جریان کار، تعداد کلیک، حالت‌های Loading/Empty/Error، ریسپانسیو، دسترسی‌پذیری
۴. پیشنهادهای بهبود برای هر مشکل، یک راه‌حل مشخص و قابل اجرا
۵. ساختار جدید صفحه چیدمان پیشنهادی، در چهارچوب همین تم
۶. کامپوننت‌های قابل استفادهٔ مجدد از components/ui که همین حالا جواب می‌دهند
۷. کامپوننت‌های نیازمند بهبود کدام Props/API باید عمومی‌تر شود و چرا
۸. کامپوننت‌های جدید فقط در صورت ضرورت، با دلیل نبودِ جایگزین
۹. دلیل هر تغییر چرا این تغییر تجربه را بهتر می‌کند
```
قانون: **اول کامپوننت موجود، بعد توسعه‌اش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
هر یافته باید به `file:line` وصل باشد یا به یکی از اسکرین‌شات‌ها. یافتهٔ بی‌ارجاع، حدس است.
### ۴. بازنویسی، سپس مقایسه
بعد از ادیت، دوباره اسکرین‌شات بگیر و با `before.png` مقایسه کن:
```bash
yarn dev # یا: yarn watch
node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
ddev exec yarn dev
node .claude/skills/redesign-page/driver.mjs variants "<همان url>" --dir /tmp/clinicpro-review-after
```
before/after را کنار هم بگذار. اگر تفاوتی دیده نمی‌شود، باندل قدیمی است.
### ۵. تست + تایپ‌چک (بدون این، تسک تمام نیست)
```bash
npx tsc --noEmit -p tsconfig.json
npx vitest run assets/admin/pages/<YourPage>.test.tsx
ddev exec npx tsc --noEmit --project tsconfig.json
npx vitest run # روی هاست، نه داخل ddev
```
توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یک‌بار `npx vitest run`
بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت می‌اندازی.
خط پایه در ۲۰۲۶-۰۸-۰۲: **۱۰۰ فایل، ۶۶۰ تست، همه سبز.** هر شکستی مالِ توست.
(نسخهٔ قبلی این سند از «۲۱ تست از پیش شکسته» می‌گفت — دیگر درست نیست.)
## چک‌لیست بازطراحی
درایور موارد گرپ‌شدنی را می‌گیرد؛ این‌ها را باید خودت با چشم ببینی:
درایور موارد گرپ‌شدنی و DOMی را می‌گیرد؛ این‌ها را باید خودت با چشم ببینی:
- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
*داخلش* می‌نشیند. اگر `<label>` داخل `.field` بگذاری، لیبل کنار اینپوت می‌چسبد؛ و اگر
`SearchableSelect` داخلش بگذاری، دو باکس تودرتو می‌شود. برای «لیبل بالای فیلد» از
`.field-block` استفاده کن.
- **`className="btn"` بدون واریانت** بی‌رنگ و بدون بوردر رندر می‌شود عملاً نامرئی.
همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
- **دکمه‌های فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را می‌گیرد.
- **سلسله‌مراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
- **`.field` در مقابل `.field-block`** — `.field` خودش باکسِ بوردردار اینپوت است. لیبل
داخلش یعنی لیبل چسبیده به اینپوت؛ `SearchableSelect` داخلش یعنی دو باکس تودرتو.
«لیبل بالای فیلد» → `.field-block`.
- **`.card` پدینگ ندارد** — `card-pad` آن را می‌دهد.
- **`className="btn"` بدون واریانت** بی‌رنگ و بی‌بوردر رندر می‌شود، عملاً نامرئی.
همیشه `btn primary` / `btn secondary` / `btn ghost` / `btn danger`.
- **دکمهٔ فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
- **`.seg`** فقط `button` و `a` را می‌شناسد و کلاس فعالش `on` است (`active` هم alias شد).
تبِ فعالِ بی‌کلاس یعنی هیچ نشانه‌ای ندارد.
- **سلسله‌مراتب** — عنوان در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
- **صفحهٔ زیرمجموعه** حتماً `backTo` یا `<BackButton fallback=…>` دارد.
- **وضعیت لیست در URL** — جستجو/فیلتر/صفحه با `useUrlState`، نه `useState`؛ وگرنه
«بازگشت» نما را می‌پراند.
- **RTL/جلالی** — رشته‌های جدید فارسی، تاریخ‌ها جلالی، اعداد با `formatNumber`/`formatRial`.
- **دارک‌مود** — چون توکن استفاده می‌کنی خودکار درست است؛ هگز هاردکد آن را می‌شکند.
- **دارک‌مود** — با توکن خودکار درست است؛ یک هگز هاردکد آن را می‌شکند.
- **سه حالت داده** — Loading / Empty / Error هر سه باید طراحی داشته باشند، نه فقط حالت پر.
## Gotchas
- **ریدایرکت خاموش نقش‌ها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بی‌صدا به
`/admin/dashboard` می‌برد. یعنی یک اسکرین‌شات کاملاً سالم از **صفحهٔ اشتباه** می‌گیری.
درایور مسیر نهایی را با مسیر درخواستی مقایسه می‌کند و هشدار می‌دهد:
- **کاربر پیش‌فرض درایور با دیتابیس فعلی هماهنگ است، ولی دیتابیس عوض می‌شود.**
پیش‌فرض `0912000201` / `QaTest@1234` (پزشکِ مالک کلینیک) است. اگر لاگین `ERR_AUTH_005`
داد، دیتابیس دوباره seed شده: `ddev exec php bin/console app:seed-scenarios --reset -n`.
در اجرای ۲۰۲۶-۰۸-۰۲ کاربر `0912000301` (مالک غیرپزشک) **رمز نداشت** و لاگینش رد شد؛
از `0912000201` یا `0912000101` استفاده کن.
- **ریدایرکت خاموش نقش‌ها.** `RoleRoute` کاربرِ بی‌مجوز را بی‌صدا به `/admin/dashboard`
می‌برد — یعنی یک اسکرین‌شات کاملاً سالم از **صفحهٔ اشتباه**. درایور مقایسه می‌کند:
```
⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
```
کاربر پیش‌فرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمی‌شوند.
برای آن‌ها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
- **محیط کاری، نه نقش.** کاربری که هم مطب شخصی دارد هم کلینیک، پیش‌فرض روی مطب می‌نشیند و
صفحهٔ منابع/سرویس‌های کلینیک **خالی** می‌آید. این باگ نیست: `--context clinic` بده.
- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
`Test@1234` را مستند می‌کند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را می‌نویسد — اول بپرس).
- **مودال نصب PWA جلوی صفحه را می‌گیرد.** درایور `pwa-dismissed=1` را seed می‌کند. با
کروم خام، این مودال وسط تصویر است.
- **مودال نصب PWA جلوی صفحه را می‌گیرد.** درایور `localStorage['pwa-dismissed']='1'` را
seed می‌کند. اگر با کروم خام اسکرین‌شات بگیری، این مودال وسط تصویر است.
- **تم فقط با صفت `data-theme` نمی‌ماند** — بعد از hydrate از `localStorage['clinicpro-ui']`
دوباره خوانده می‌شود. درایور هر دو را می‌نویسد و بعد از رندر یک بار دیگر صفت را می‌گذارد.
- **موبایل بدون `Emulation.setDeviceMetricsOverride` فقط «پنجرهٔ باریک» است** — مدیا
کوئری‌های `pointer: coarse` خاموش می‌مانند و ارتفاع لمسی ۴۴px دیده نمی‌شود. درایور برای
عرض ≤۴۸۰ خودش این را روشن می‌کند.
- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
توکن می‌دهد؛ درایور به همین تکیه می‌کند. اگر روی محیطی که کپچا را اجبار می‌کند اجرا شود، می‌شکند.
توکن می‌دهد؛ درایور به همین تکیه می‌کند و روی محیطی که کپچا را اجبار کند می‌شکند.
- **سرت ddev را Node رد می‌کند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
هاست‌های `*.ddev.site` / `localhost` تأیید TLS را خاموش می‌کند، نه برای هر مبدأ.
`*.ddev.site` / `localhost` تأیید TLS را خاموش می‌کند، نه برای هر مبدأ.
- **صفحهٔ نوبت‌ها خودش اسکرول می‌شود** به ساعت جاری، پس ویوپورت وسط تایم‌لاین می‌افتد.
برای دیدن هدر از `--full` استفاده کن.
- **صفحهٔ نوبت‌ها خودش تا ساعت جاری اسکرول می‌کند**، پس ویوپورت وسط تایم‌لاین می‌افتد.
برای دیدن هدر `--full` بده.
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` می‌دهد** — از قبل وجود دارد و جلوی
کامپایل JS/TS را نمی‌گیرد. خطاهای TypeScript همچنان در خروجی `tsc` می‌آیند.
- **منوی تنظیمات دو مصرف‌کننده دارد** — `settingsMenu.ts` منبع واحد است؛ سایدبار دسکتاپ و
فهرست موبایل هر دو از آن می‌خوانند. در موبایل کل منو **بالای** محتوا می‌نشیند، پس صفحهٔ
تنظیماتی در نمای ۳۹۰px یعنی ۱۵ آیتم منو قبل از رسیدن به محتوا. در نقد موبایل حتماً ببینش.
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` می‌دهد** — از قبل هست و جلوی کامپایل
JS/TS را نمی‌گیرد؛ خطاهای TypeScript همچنان در خروجی `tsc` می‌آیند.
## Troubleshooting
| علامت | علت / راه‌حل |
|---|---|
| `login failed for 0912000201: … ERR_AUTH_005` | دیتابیس دوباره seed شده. `ddev exec php bin/console app:seed-scenarios --reset -n` یا `CLINICPRO_USER`/`CLINICPRO_PASS` را ست کن. |
| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
| `redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً JWT منقضی شده — دوباره اجرا کن. |
| `WRONG PAGE` | نقشِ کاربر اجازه ندارد، یا محیط اشتباه است (`--context clinic`). |
| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
| اسکرین‌شات تغییرات را نشان نمی‌دهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
| اسکرین‌شات تغییرات را نشان نمی‌دهد | باندل قدیمی است. `ddev exec yarn dev` (یا `yarn watch` روشن). |
| صفحهٔ کلینیک خالی است ولی خطا ندارد | محیط روی مطب شخصی است. `--context clinic`. |
+288 -17
View File
@@ -4,27 +4,39 @@
* SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
* CDP directly, so there is no playwright/puppeteer install to babysit).
*
* node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
* node .claude/skills/redesign-page/driver.mjs inspect <url>
* node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
* driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
* [--theme dark] [--density compact] [--context clinic]
* driver.mjs variants <url> [--dir /tmp/review] ← the four shots a review needs
* driver.mjs inspect <url>
* driver.mjs audit <file.tsx>
* driver.mjs ds [components|tokens]
*
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
* navigates and captures. Needed because the admin is a client-side
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
* the design-system components it already imports.
* `audit` greps one source file for the anti-patterns this project keeps
* regrowing (native <select>, hardcoded hex, dead tokens, …).
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
* navigates and captures. Needed because the admin is a client-side
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
* `variants` runs `shot` four times — light desktop, dark, compact, 390px mobile.
* A redesign judged on one screenshot ships a page that breaks in the
* other three; dark mode and compact density are real user settings
* here, not hypotheticals.
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
* the design-system components it already imports.
* `audit` greps one source file for the anti-patterns this project keeps
* regrowing (native <select>, hardcoded hex, dead tokens, …) plus the
* accessibility misses that never fail a build.
* `ds` prints the design system — tokens from styles.css and the shared
* components — so a redesign starts from what exists.
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const USER = process.env.CLINICPRO_USER ?? '09390039833';
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
// دو کاربرِ سیدر که رمز دارند: 0912000101 پزشک مستقل، 0912000201 پزشکِ مالک کلینیک.
// دومی هر دو محیط را دارد، پس بیشترین صفحه با آن باز می‌شود.
const USER = process.env.CLINICPRO_USER ?? '0912000201';
const PASS = process.env.CLINICPRO_PASS ?? 'QaTest@1234';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9333);
@@ -70,6 +82,33 @@ function cdp(ws) {
});
}
/**
* ورود با OTP — در dev کد ثابتِ `12345` است (OtpService::sendCode)، پس کل زنجیرهٔ
* send-code → verify-code → otp-login اسکریپت‌پذیر است.
*
* راهِ نجاتِ دیتابیسی که seed نشده: کاربر واقعیِ چنین دیتابیسی ممکن است اصلاً
* `password_hash` نداشته باشد یا رمزش را ندانیم، ولی شمارهٔ موبایلش کافی است.
*/
async function otpLogin(mobile) {
const post = async (path, body) => {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const sent = await post('/api/v1/user/send-code', { mobile });
if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 160)}`);
const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
const grant = ver?.data?.grant;
if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 160)}`);
return post('/api/v1/user/otp-login', { grant });
}
async function login() {
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
@@ -77,12 +116,62 @@ async function login() {
body: JSON.stringify({ mobile_number: USER, password: PASS }),
});
const j = await r.json();
if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
return j;
if (j.access_token) return j;
// رمز نخورد؛ با OTP امتحان کن. دیتابیس‌های واقعی (نه seed) رمزِ مستندشده ندارند و
// بدون این، تنها راه یا reset کردن دیتابیس کاربر بود یا نوشتنِ رمز روی حسابش.
const otp = await otpLogin(USER).catch((e) => ({ _err: e.message }));
if (otp.access_token) return otp;
// `send-code` سقف ۵ بار در ساعت دارد. وقتی سوخت، به‌جای شل‌کردن یک محدودیتِ واقعیِ
// محصول برای تست، توکن را با کامندِ خودِ اپ می‌سازیم — همان کلید و همان claimها.
try {
const out = execSync(
`ddev exec 'php bin/console lexik:jwt:generate-token ${USER} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const minted = out.trim().split('\n').pop().trim();
if (minted.startsWith('ey')) return { access_token: minted, refresh_token: null };
} catch { /* کامند در دسترس نیست؛ می‌افتیم روی خطای زیر */ }
throw new Error(
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
+ ` → OTP fallback also failed: ${otp._err ?? JSON.stringify(otp).slice(0, 120)}\n`
+ ' → CLINICPRO_USER را روی شمارهٔ یک کاربر واقعیِ همین دیتابیس بگذار،\n'
+ ' یا حساب‌های تست را بساز: ddev exec php bin/console app:seed-scenarios --reset -n',
);
}
/**
* محیط کاری را عوض می‌کند. کاربری که هم مطب شخصی دارد هم کلینیک، پیش‌فرض روی مطب
* می‌نشیند و صفحه‌های کلینیک خالی می‌آیند — که شبیه باگ است ولی نیست.
*/
async function switchContext(token, kind) {
// فهرست محیط‌ها فقط در `/oauth/userinfo` است. مسیر قبلی (`/api/v1/user/me`) اصلاً
// وجود ندارد و ۴۰۴ می‌داد، پس `--context clinic` همیشه بی‌صدا نادیده گرفته می‌شد و
// اسکرین‌شاتِ محیطِ اشتباه گرفته می‌شد.
const me = await (await fetch(`${BASE}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${token}` },
})).json().catch(() => ({}));
const contexts = me?.data?.available_contexts ?? me?.available_contexts ?? [];
const want = contexts.find((c) => (c.type ?? c.scope) === kind);
if (!want) {
console.log(`⚠ no "${kind}" context for this user — staying where we are`);
return;
}
await fetch(`${BASE}/api/v1/auth/switch-context`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ db_uuid: want.db_uuid }),
});
console.log(`context → ${kind} (${want.name ?? want.db_uuid})`);
}
async function shot(url, opts) {
const { access_token, refresh_token } = await login();
if (opts.context) await switchContext(access_token, opts.context);
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
@@ -105,6 +194,15 @@ async function shot(url, opts) {
await S('Page.enable');
await S('Runtime.enable');
// موبایل بدون این فلگ فقط «پنجرهٔ باریک» است: مدیا کوئری‌های pointer: coarse
// خاموش می‌مانند و ارتفاع لمسی ۴۴px که برای موبایل نوشته شده دیده نمی‌شود.
if (opts.w <= 480) {
await S('Emulation.setDeviceMetricsOverride', {
width: opts.w, height: opts.h, deviceScaleFactor: 2, mobile: true,
});
await S('Emulation.setTouchEmulationEnabled', { enabled: true });
}
// localStorage is origin-scoped, so the origin must be loaded before seeding.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
@@ -115,20 +213,62 @@ async function shot(url, opts) {
},
version: 0,
};
// تم و تراکم را uiStore در همان localStorage نگه می‌دارد و روی <html> می‌نشاند؛
// ست‌کردن مستقیم صفت، بعد از hydrate پس گرفته می‌شود، پس استور هم نوشته می‌شود.
const ui = { state: { darkMode: opts.theme === 'dark', density: opts.density }, version: 0 };
await S('Runtime.evaluate', {
expression: `
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('clinicpro-ui', ${JSON.stringify(JSON.stringify(ui))});
localStorage.setItem('pwa-dismissed', '1');
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
`,
});
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
// مودال‌ها فقط با تعامل باز می‌شوند و بدون این، نقدشان ممکن نیست: --click یک
// متنِ دیدنی یا سلکتور می‌گیرد، اولین تطابق را می‌زند و منتظر رندر می‌ماند.
if (opts.click) {
const clicked = await S('Runtime.evaluate', {
returnByValue: true,
expression: `(() => {
const q = ${JSON.stringify(opts.click)};
let el = null;
try { el = document.querySelector(q); } catch {}
if (!el) {
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
}
if (!el) return 'not found: ' + q;
el.scrollIntoView({ block: 'center' });
el.click();
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
})()`,
});
console.log(' CLICK', clicked?.result?.value ?? '—');
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
}
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
await S('Runtime.evaluate', {
expression: `
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
`,
});
await new Promise((r) => setTimeout(r, 400));
const { data } = await S('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: opts.full,
});
mkdirSync(dirname(resolve(opts.out)), { recursive: true });
writeFileSync(opts.out, Buffer.from(data, 'base64'));
console.log(`${opts.out}`);
@@ -149,12 +289,84 @@ async function shot(url, opts) {
console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
}
if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
if (opts.probe) await probeRuntime(S);
ws.close();
} finally {
chrome.kill();
}
}
/**
* چیزهایی که فقط در DOMِ رندرشده دیده می‌شوند و هیچ گرپی رویشان نمی‌افتد:
* سرریز افقی، دکمهٔ بی‌نام، و فیلد بدون لیبل.
*/
async function probeRuntime(S) {
const { result } = await S('Runtime.evaluate', {
returnByValue: true,
expression: `(() => {
const out = [];
if (document.documentElement.scrollWidth > window.innerWidth + 2) {
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
+ 'px wide in a ' + window.innerWidth + 'px viewport');
}
// شمارش تنها می‌گوید «۲ تا»، نه «کدام دو تا» — و حدس زدنش وقت تلف کردن است.
const where = (el) => {
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
const near = el.closest('[class]');
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
+ (txt ? ' «' + txt + '»' : '')
+ (near && near !== el && typeof near.className === 'string'
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
};
const list = (arr) => arr.map(where).join(' · ');
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
.filter(b => !(b.innerText || '').trim()
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
&& !i.closest('label'));
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
const tiny = [...document.querySelectorAll('button, a')]
.filter(b => { const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.height < 32; });
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
return out;
})()`,
});
const findings = result.value ?? [];
console.log(findings.length ? 'RUNTIME\n' + findings.map((f) => ' ⚠ ' + f).join('\n')
: 'RUNTIME clean');
}
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
async function variants(url, dir, extra = {}) {
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
const runs = [
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
{ name: 'dark', w: 1440, h: 900, theme: 'dark', density: 'comfortable' },
{ name: 'compact', w: 1440, h: 900, theme: 'light', density: 'compact' },
{ name: 'mobile', w: 390, h: 844, theme: 'light', density: 'comfortable' },
];
for (const r of runs) {
console.log(`\n── ${r.name} ${r.w}×${r.h} ${r.theme}/${r.density}`);
await shot(url, {
out: `${dir}/${slug}-${r.name}.png`,
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
theme: r.theme, density: r.density, context: null, ...extra,
});
}
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
}
// ── Static inspection ──────────────────────────────────────────────────────
/** URL path → the <Route> line in App.tsx → the page component file. */
@@ -192,6 +404,8 @@ function inspect(url) {
const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
console.log(`lines ${src.split('\n').length}`);
const test = file.replace(/\.tsx$/, '.test.tsx');
console.log(`test ${existsSync(`${REPO}/${test}`) ? test : '— none, write one'}`);
auditSource(file, src);
}
}
@@ -209,16 +423,56 @@ function auditSource(label, src) {
push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');
push(/new Date\([^)]*\)\.toLocaleDateString\((?!'fa)/, 'Gregorian date — use formatDate() (Jalali)');
push(/type="date"/, 'native date input — use PersianDateInput');
// آیکون تنها داخل دکمه، بدون aria-label: در تست سبز است و برای screen reader بی‌نام.
src.split('\n').forEach((line, i) => {
if (/<button(?![^>]*aria-label)/.test(line) && /Icon\b/.test(line) && !/>\s*[^\s<]/.test(line)) {
findings.push(`${label}:${i + 1} icon-only <button> with no aria-label`);
}
});
// var(--x) references that styles.css never defines (e.g. the dead --error).
for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
}
// .seg فقط کلاس on/active را می‌شناسد؛ هر چیز دیگری یعنی تب فعال بی‌نشانه.
if (/className="seg"/.test(src) && !/'(on|active)'/.test(src)) {
findings.push(`${label} .seg without an on/active class — the selected tab has no highlight`);
}
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
: '\nAUDIT clean');
}
/** دیزاین‌سیستم موجود — قدم اول هر بازطراحی، پیش از نوشتن یک خط JSX. */
function designSystem(what) {
const css = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
if (what !== 'components') {
const root = css.match(/^:root\s*\{([\s\S]*?)^\}/m)?.[1] ?? '';
const vars = [...root.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => ` ${m[1]}: ${m[2].trim()}`);
console.log(`TOKENS (${vars.length}) — assets/admin/styles.css`);
console.log(vars.join('\n'));
const classes = [...new Set([...css.matchAll(/^\.([\w-]+)[\s,{:]/gm)].map((m) => m[1]))];
console.log(`\nCLASSES (${classes.length})\n ${classes.join(' · ')}`);
}
if (what !== 'tokens') {
const dir = `${REPO}/assets/admin/components/ui`;
const ui = readdirSync(dir).filter((f) => f.endsWith('.tsx') && !f.endsWith('.test.tsx'));
console.log(`\nSHARED COMPONENTS (${ui.length}) — assets/admin/components/ui/`);
for (const f of ui) {
const src = readFileSync(`${dir}/${f}`, 'utf8');
const props = src.match(/interface Props\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
const names = [...props.matchAll(/^\s*\/?\*?\s*(\w+)\??:/gm)].map((m) => m[1]);
console.log(` ${f.replace('.tsx', '').padEnd(26)} ${names.slice(0, 8).join(', ')}`);
}
}
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [cmd, arg, ...rest] = process.argv.slice(2);
@@ -231,15 +485,32 @@ if (cmd === 'shot' && arg) {
h: Number(flag('h', 900)),
wait: Number(flag('wait', 4000)),
full: rest.includes('--full'),
probe: !rest.includes('--no-probe'),
theme: flag('theme', 'light'),
density: flag('density', 'comfortable'),
context: flag('context', null),
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} else if (cmd === 'variants' && arg) {
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} else if (cmd === 'inspect' && arg) {
inspect(arg);
} else if (cmd === 'audit' && arg) {
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
} else if (cmd === 'ds') {
designSystem(arg);
} else {
console.log(`usage:
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
[--theme light|dark] [--density comfortable|compact]
[--context clinic|personal] [--no-probe]
driver.mjs variants <url> [--dir /tmp/clinicpro-review]
driver.mjs inspect <url>
driver.mjs audit <path/to/File.tsx>`);
driver.mjs audit <path/to/File.tsx>
driver.mjs ds [tokens|components]`);
process.exit(1);
}
+1 -1
View File
@@ -3,7 +3,7 @@ name: run-prompt
description: اجرای یک فایل پرامپت .md به صورت گام‌به‌گام و ایمن. هر قابلیت را جداگانه پیاده‌سازی، تست و مستند می‌کند. استفاده کن وقتی کاربر می‌گوید "اجرای پرامپت"، "پرامپت را اجرا کن"، "run prompt"، یا مسیر یک فایل .md می‌دهد.
---
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، todo، تعریف «تمام شد»، مستندات، SOLID، رفتار تحلیل‌گر، چک‌لیست پایانی §۷) را تعیین می‌کند؛ این skill فقط قواعد stack، دستورهای تست و مسیرها را دارد. در تناقض با متن پرامپت، guidelines برنده است — مگر کاربر صریحاً خلافش را بگوید.
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخش‌های آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، todo، تعریف «تمام شد»، مستندات، SOLID، رفتار تحلیل‌گر، چک‌لیست پایانی §۷) را تعیین می‌کند؛ این skill فقط قواعد stack، دستورهای تست و مسیرها را دارد. در تناقض با متن پرامپت، guidelines برنده است — مگر کاربر صریحاً خلافش را بگوید.
## نحوه دریافت ورودی
+4 -1
View File
@@ -1,6 +1,9 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
# مقدارِ ثابت و آشکارا غیرعملیاتی: محیط تست هیچ‌وقت به داده یا کاربر واقعی وصل
# نمی‌شود، و همین شفافیت جلوی این را می‌گیرد که کسی این فایل را منبع یک secret
# واقعی بپندارد. secret واقعی فقط در `.env.local` و خارج از git است.
APP_SECRET='not-a-secret-test-env-only'
# Test DB: doctrine's when@test config appends the `_test` suffix (see
# config/packages/doctrine.yaml), so this base name `db` becomes `db_test`.
+3
View File
@@ -59,3 +59,6 @@ clinicpro/*
# Database backups (never commit dumps to the repo)
*.sql
*.sql.gz
# اسکرین‌شات‌های راهنمای کاربری — با guide-shots.mjs بازتولید می‌شوند و ۱۷ مگابایت‌اند
docs/guide/images/
+69
View File
@@ -0,0 +1,69 @@
# ClinicPro Backend
The Symfony API and bundled React admin that owns all clinic data, scheduling and auth for the
ClinicPro product. This glossary records the language the backend uses for its own concepts.
## Language
### Clinic configuration
**Practice Domain**:
The single field of practice a clinic declares it operates in — beauty, dental, orthopaedics. It is a
configuration key: it selects which dashboard, forms and treatment workflows the clinic gets. A
clinic has exactly one.
_Avoid_: Specialty, clinic type, field, discipline
**Specialty**:
A medical specialty label attached to a Clinic or a Doctor, used by the public booking site for
listing and SEO. It is descriptive, not configuration — it never selects behaviour.
_Avoid_: Practice Domain, category
### Treatment
**Treatment Protocol**:
The template attached to a service that says a course of that service runs over several sessions, when
each one falls due, which doctor supervises the course, and which staff are allowed to perform it.
Defined once by the clinic manager, not per patient.
_Avoid_: Treatment plan, course template, session config
**Supervising Doctor**:
The doctor answerable for a Treatment Protocol and for every Treatment Case opened from it. They carry
clinical responsibility; they do not necessarily perform the treatment.
_Avoid_: Doctor, owner, responsible
**Operator**:
The staff member who actually performs a Treatment Session — runs the device, treats each area, and
records what was done. Chosen at booking time from the staff the Treatment Protocol allows.
_Avoid_: Technician, performer, staff, nurse
**Treatment Case**:
One patient's run of a Treatment Protocol, from the first booking until the course ends. It owns the
sessions and carries the state of the whole course.
_Avoid_: Treatment plan, patient file, dossier, episode
**Treatment Session**:
One numbered step of a Treatment Case — session 3 of 8. It exists whether or not it has been booked
yet, so its number survives cancellation and rescheduling. It holds the plan and the clinical record,
never money.
_Avoid_: Appointment, visit, session slot, patient session
**Visit Record**:
The billing side of one attended visit — services rendered, insurance shares, discounts, payments. It
comes into existence when an Appointment is confirmed, so a Treatment Session that has not been booked
yet has none.
_Avoid_: Session, invoice, patient session
**Appointment**:
A reserved slot in a calendar. It is the booking, not the treatment. A Treatment Session may point at
one, at a different one after rescheduling, or at none while still unbooked.
_Avoid_: Session, booking, reservation
**Treatment Area**:
A region of the body a Treatment Session is performed on — underarms, bikini line. A single session
covers several, each completed and recorded on its own.
_Avoid_: Zone, body part, region
**Session Area Record**:
What the operator actually did to one Treatment Area in one Treatment Session — the device used, the
parameters it was set to, how long it took, and any note.
_Avoid_: Treatment log, area result, shot record
+124 -97
View File
@@ -1,110 +1,137 @@
# کاربران تستی
**پنل ادمین:** https://clinic-pro.ddev.site/admin
**پنل ادمین:** https://clinic-pro.ddev.site/admin**سایت عمومی:** `yasuj-nobat.localhost:3000`
> رمز عبور همهٔ پرسوناها: `QaTest@1234`
> رمز عبور همهٔ حساب‌های کارکنان: `QaTest@1234` · کد OTP در dev همیشه `12345`
این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را
توصیف می‌کند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل
تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است.
---
## پرسوناها
واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR`
دارند ولی دادهٔ متفاوتی می‌بینند.
| پرسونا | موبایل | نقش‌ها | تمایز |
|---|---|---|---|
| `admin` | `09120671756` | `ROLE_ADMIN` | — |
| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» |
| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` |
| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک |
| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری |
| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک |
| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» می‌بیند |
| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» |
| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک |
| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — |
| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت |
| `importer` | `09129000007` | `ROLE_IMPORTER` | — |
`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ
ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» می‌گیرند. این باگ نیست:
`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد می‌کند.
## شناسه‌ها
| موجودیت | نام | UUID |
|---|---|---|
| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
| پروفایل تصاحب‌نشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` |
| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقه‌ای)
دارند، پس صفحات نوبت‌دهی‌شان خالی نیستند.
## دادهٔ انبوه
`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰
نوبت می‌سازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست.
---
## بازسازی از صفر
ترتیب اجباری است — وابستگی‌ها چرخه‌ای‌اند:
کل این محیط با **یک دستور** ساخته می‌شود:
```bash
ddev exec php bin/console doctrine:schema:drop --full-database --force
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234'
# نماینده‌ها باید قبل از شهرها باشند: data/seed/cities.json به representation_id
# های ۱ تا ۳ ارجاع می‌دهد و app:seed-categories اعتبارسنجی‌شان می‌کند.
# POST /api/v1/representation ×۳ (با توکن ادمین)
ddev exec php bin/console app:seed-categories --no-interaction
ddev exec php bin/console app:seed-sms-message-templates --no-interaction
ddev exec php bin/console app:seed-demo-data --purge --no-interaction
ddev exec php bin/console app:seed-scenarios --reset -n
```
`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نماینده‌های مرحلهٔ قبل را
purge و بازسازی می‌کند؛ بعد از آن `cities.representation_id` به شناسه‌های قدیمی اشاره
می‌کند و باید به شناسه‌های جدید نگاشت شود.
`--reset` دیتابیس را کامل خالی می‌کند، migrationها را از صفر می‌زند، دادهٔ پایه
(نماینده‌ها → دسته‌بندی‌ها → کاتالوگ بیمه → خاموش‌کردن کپچا) را می‌سازد و بعد سه سناریو
را می‌چیند. بدون `--reset` فقط سناریوها ساخته می‌شوند و روی دیتابیسی که قبلاً seed شده
با خطا برمی‌گردد.
سپس پرسوناها از راه اندپوینت‌های خود اپ ساخته می‌شوند:
`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` ·
`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` ·
`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` ·
`send-code → verify-code → register` برای `patient`.
> **UUIDها با هر اجرا عوض می‌شوند.** جدول‌های زیر شکل داده را نشان می‌دهند نه شناسه‌های
> ثابت؛ برای گرفتن uuidهای فعلی از خود API یا دیتابیس بپرسید.
`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش
در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient`
را می‌شناسد، پس این دو با SQL مستقیم ست می‌شوند.
---
## سناریوی ۱ — پزشک مستقل، نوبت‌دهی سرویسی
| نقش | موبایل | توضیح |
|---|---|---|
| پزشک | `0912000101` | سارا مرادی — مطب شخصی، بدون کلینیک |
| منشی | `0912000109` | منشیِ همان پزشک |
| بیماران | `09120001100``09120001104` | ۵ بیمار با پرونده |
- **حالت نوبت‌دهی:** `service` · بافر ۱۰ دقیقه · شنبه تا چهارشنبه ۰۹:۰۰–۱۷:۰۰
- **سرویس‌ها:** مشاوره پوست (۲۰د) · لیزر صورت (۳۰د، additional ۲۰) · تزریق ژل (۴۵د) ·
میکرونیدلینگ (۶۰د، additional ۴۰)
- **بیمه:** تامین اجتماعی، بیمه ایران
- **نوبت‌ها:** ۹ نوبت در ۵ وضعیت (انجام‌شده، لغو کاربر، عدم حضور، تأییدشده، در انتظار پرداخت)
که دو تای آن‌ها **امروز** است
## سناریوی ۲ — پزشکی که مالک کلینیک است
| نقش | موبایل | توضیح |
|---|---|---|
| پزشک + مالک کلینیک | `0912000201` | امیر کاظمی — مالک «کلینیک تخصصی مهر»، خودش هم در کلینیک نوبت می‌دهد |
| پزشک عضو (سرویسی) | `0912000202` | نگار سلطانی |
| پزشک عضو (اسلاتی) | `0912000203` | بهرام فتحی |
| پزشک عضو (اسلاتی) | `0912000204` | الهام قاسمی |
| منشی | `0912000209` | روی هر ۴ پزشک |
| بیماران | `09120001200``09120001207` | ۸ بیمار |
- **ترکیب حالت‌ها:** ۲ سرویسی + ۲ اسلاتی، همه شنبه تا چهارشنبه ۱۶:۰۰–۲۱:۰۰
- **دستگاه‌ها:** ۲ لیزر (الکساندرایت، دایود) با setup/cleanup و تقویم کاری + ۲ اتاق درمان
- **سرویس‌ها:** لیزر کامل بدن (۹۰د) · لیزر زیربغل (۲۰د) · ویزیت پوست (۱۵د) · هیدرودرم (۴۵د)
- **وابستگی به دستگاه:** دو سرویس لیزری هرکدام یک بخش (`SegmentTemplate`) با نیازمندی
انحصاری روی نوع منبع «دستگاه لیزر» دارند
- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه آسیا
این حساب **دو محیط کاری** دارد (مطب شخصی + کلینیک). سرویس‌ها و دستگاه‌ها زیر محیط
*کلینیک* اند، پس تا وقتی محیط عوض نشود لیستشان خالی است — این درست است، نه باگ:
```bash
POST /api/v1/auth/switch-context {"db_uuid": "<clinic_uuid>"}
```
## سناریوی ۳ — کلینیک مستقل با مالکِ غیرپزشک
| نقش | موبایل | توضیح |
|---|---|---|
| مالک کلینیک | `0912000301` | رضا شریفی — **پزشک نیست**، فقط `ROLE_CLINIC` |
| پزشک (اسلاتی) | `0912000302` | پیمان اکبری |
| پزشک (سرویسی) | `0912000303` | مینا یوسفی |
| پزشک (منبع‌محور) | `0912000304` | آرش نوری |
| منشی | `0912000309` | روی هر ۳ پزشک |
| بیماران | `09120001300``09120001307` | ۸ بیمار |
- **هر سه حالت نوبت‌دهی** اینجا زنده‌اند: `slot`، `service` و `resource`
- **ساعت کاری:** شنبه تا پنجشنبه ۰۸:۰۰–۱۴:۰۰
- **دستگاه‌ها:** ۳ لیزر (CO2 فرکشنال، NdYAG، دایود) + دستگاه RF + دستگاه کرایو
- **سرویس‌ها:** ویزیت عمومی (۱۵د) · لیزر CO2 (۴۰د) · کرایوتراپی (۲۵د) · RF فرکشنال (۵۰د)
- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه دی
مالکِ غیرپزشک عمدی است: هر مسیری که فرض کند مالکِ کلینیک یک `Doctor` دارد، باید
همین‌جا بشکند نه در محیط واقعی.
---
## موتور نوبت‌دهی جدید (تسک‌های `docs/new_feture`)
هر سه محیط، علاوه بر داده‌های بالا، دادهٔ زندهٔ موتور جدید هم دارند:
| تسک | چه چیزی seed می‌شود | کجا دیده می‌شود |
|---|---|---|
| ۰۱ شعبه و اتاق | `DoctorAddress` به‌عنوان شعبه + منبع اتاق با تقویم | `GET /api/v1/resources` |
| ۰۲ مدل منبع | نوع منبع، دستگاه، **مهارت** (`کار با لیزر`) با سطح، **استخر منبع** با اولویت | `GET /api/v1/resources` · `/skills` |
| ۰۳ تقویم منبع | تقویم هفتگی هر دستگاه + یک **استثنای تعمیرات** هفتهٔ بعد | جستجوی آزاد آن بازه را رد می‌کند |
| ۰۴ کاتالوگ نسل دوم | دستهٔ درختی، **گروه انتخاب** با بازهٔ ۱ تا ۲، **ناسازگاری** دو سرویس، **override شعبه**، مدت solo/additional | `GET /api/v1/service-items` |
| ۰۵ برنامهٔ چندبخشی | سرویس اصلی هر محیط سه بخش دارد: بی‌حسی ۵د (اتاق انحصاری) → انتظار ۳۰د (اتاق **passive**) → لیزر ۲۰د (اتاق + دستگاه) | `GET /api/v1/service-item/{uuid}/segments` |
| ۰۶ موتور دسترس‌پذیری | همان برنامه با منابع واقعی جستجو می‌شود | `POST /api/v1/appointment-availability` |
| ۰۷ نگه‌داشتن و ثبت | **۲ نوبت در هر محیط از مسیر واقعی** hold → confirm، با بخش و اشغال منبع | `GET /api/v1/appointment/{uuid}/segments` |
| **منبع↔سرویس** | رابطهٔ چند‌به‌چند با مدت و قیمت اختصاصی: دو دستگاه یک سرویس را با اعداد متفاوت می‌دهند | `GET /api/v1/resource/{uuid}/services` |
| **دستهٔ مشترک** | دسته روی سرویس و منبع؛ یال «شامل بودن» بین دسته‌ها | مودال «سرویس‌ها» در صفحهٔ منابع |
| ۰۸ عکس قیمت | لیست قیمت فعال + `PriceSnapshot` روی نوبت‌های واقعی با بیعانه | `GET /api/v1/price-lists` |
> **تسک‌های ۹ تا ۱۴ (سیاست، پکیج، دوره، لغو/انتظار، رویداد و گزارش) به تصمیم مالک محصول
> از محصول حذف شده‌اند.** مدل فعلی فقط منبع، سرویس و گزینه است —
> [`docs/architecture/resource-first-model.md`](docs/architecture/resource-first-model.md).
نوبت‌های موتور جدید **از `new Appointment(...)` ساخته نمی‌شوند**؛ از
`AppointmentPlanBuilder``AvailabilityEngine``HoldService``BookingService` رد
می‌شوند. برای همین اشغال منبع واقعی است و تقویم دستگاه واقعاً پر است — نه ردیف‌هایی که
با INSERT ساخته شده‌اند و هیچ‌وقت از موتور رد نشده‌اند.
## چه چیزی با این داده قابل تست است
| قابلیت | کجا |
|---|---|
| نوبت‌دهی اسلاتی | `0912000203` · `0912000204` · `0912000302` |
| نوبت‌دهی سرویسی | `0912000101` · `0912000201` · `0912000202` · `0912000303` |
| نوبت‌دهی منبع‌محور | `0912000304` (با تخصیص واقعی دستگاه) |
| مطب شخصی در برابر کلینیک | سناریوی ۲ (یک کاربر، دو محیط) |
| مالک پزشک در برابر مالک اداری | سناریوی ۲ در برابر سناریوی ۳ |
| دستگاه و وابستگی سرویس به دستگاه | سناریوی ۲ و ۳ |
| بیمه (پایه و تکمیلی، فرانشیز، سقف) | هر سه محیط |
| پرداخت | ۳۲ پرداخت موفق روی نوبت‌های پرداخت‌شده |
| پرونده بیمار | ۲۱ پرونده با کد ملی روی پروفایل |
## نکته‌ها
- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و
ثبت‌نام اسکریپتی رد می‌شود. از پنل ادمین یا
`PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن.
- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`).
- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.** برای تست‌های انبوه توکن را
مستقیم بساز:
`ddev exec 'php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"'`
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
یا `/api/v1/admin/clinic` ساخته می‌شوند رمز نمی‌گیرند.
## نام پزشک بدون عنوان
نام ذخیره‌شدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با
`PersianText::stripDoctorTitle()` حذف می‌کنند. افزودن عنوان کار لایهٔ نمایش است —
`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده می‌کند (idempotent)،
و پنل ادمین اصلاً عنوان اضافه نمی‌کند.
پاک‌سازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run`
- **کپچا:** روی دیتابیس تازه پیش‌فرضش **روشن** است و هیچ‌کس نمی‌تواند وارد شود.
سیدر ردیف `site_config.altcha_enabled = '0'` را می‌گذارد تا محیط قابل ورود بماند.
- **لاگین با رمز فقط برای کارکنان است.** فیلد درخواست `mobile_number` است نه `mobile`:
`POST /api/v1/user/login {"mobile_number": "...", "password": "..."}`.
بیماران (`ROLE_USER`) عمداً فقط با OTP وارد می‌شوند
(`send-code``verify-code` با `12345``otp-login`).
- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.**
- **نام پزشک بدون عنوان ذخیره می‌شود** — «دکتر» را لایهٔ نمایش اضافه می‌کند
(`PersianText::stripDoctorTitle`).
- **دادهٔ انبوه** جداست: `app:seed-demo-data` حدود ۵۰۰ پزشک و ۲۰هزار نوبت با INSERT خام
می‌سازد و برای تست کارایی و ماژول نمایندگان است، نه برای تست سناریویی.
+61 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router';
import { useAuthStore } from './stores/authStore';
import { usePermissions } from './hooks/usePermissions';
import AdminLayout from './components/layout/AdminLayout';
@@ -59,6 +59,11 @@ import MyFinancialPage from './pages/MyFinancialPage';
import ClinicFormPage from './pages/ClinicFormPage';
import PreRegistrationsPage from './pages/PreRegistrationsPage';
import StaffPage from './pages/StaffPage';
import StaffTreatmentSessionsPage from './pages/StaffTreatmentSessionsPage';
import StaffSessionDetailPage from './pages/StaffSessionDetailPage';
import TreatmentCasesPage from './pages/TreatmentCasesPage';
import PracticeDomainSettingsPage from './pages/PracticeDomainSettingsPage';
import AdminPracticeDomainsPage from './pages/AdminPracticeDomainsPage';
import SubscriptionPage from './pages/SubscriptionPage';
import DiscountsPage from './pages/DiscountsPage';
import ClinicServicesPage from './pages/ClinicServicesPage';
@@ -69,10 +74,19 @@ import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
import SettingsMenuPage from './pages/SettingsMenuPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import RecordNumberSettingsPage from './pages/RecordNumberSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import ResourcesPage from './pages/ResourcesPage';
import ResourceTypesPage from './pages/ResourceTypesPage';
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
import SkillsPage from './pages/SkillsPage';
import ResourcePoolsPage from './pages/ResourcePoolsPage';
import ResourceDetailPage from './pages/ResourceDetailPage';
import HolidaysSettingsPage from './pages/HolidaysSettingsPage';
import NationalHolidaysPage from './pages/NationalHolidaysPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
import PatientDetailPage from './pages/PatientDetailPage';
import PaymentSuccessPage from './pages/PaymentSuccessPage';
@@ -80,7 +94,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
// ── Guards ──────────────────────────────────────────────────────────────────
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'staff', 'representation'] as const;
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
@@ -119,12 +133,18 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/** `/resources/{uuid}/calendar` قدیمی → تب «ساعات کاری» صفحهٔ منبع. */
function ResourceCalendarRedirect() {
const { resourceUuid } = useParams<{ resourceUuid: string }>();
return <Navigate to={`/admin/resources/${resourceUuid}?tab=hours`} replace />;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
}
function RoleRoute({ roles, blockClinicScope, permission, children }: {
export function RoleRoute({ roles, blockClinicScope, permission, children }: {
roles: string[];
blockClinicScope?: boolean;
/**
@@ -139,7 +159,23 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: {
const context = useAuthStore((s) => s.context);
const { can } = usePermissions();
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
/**
* پزشکِ عضو در محیط کلینیک برای گِیتِ نقشی همان «clinic» حساب میشود ولی **فقط**
* وقتی صفحه مجوزی اعلام کرده و کلینیک آن مجوز را به او داده باشد.
*
* بدون این، منو و route با هم نمیخواندند: `settingsMenu` واریانتِ نقشیِ همین پزشک را
* با `clinic` تطبیق میداد و آیتم را نشان میداد، ولی اینجا `primaryRole` او `doctor`
* بود و کلیک به داشبورد میپرید یعنی منویی که کار نمیکرد.
*
* قید «مجوز الزامی» عمدی است: بدون آن، هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز
* میشد. با آن، دسترسی همان چیزی میماند که مالک کلینیک صراحتاً داده است.
*/
const clinicScopedDoctor = primaryRole === 'doctor' && context?.scope === 'clinic';
const roleOk = roles.includes(primaryRole)
|| (clinicScopedDoctor && roles.includes('clinic') && !!permission && can(permission[0], permission[1]));
if (!roleOk) return <Navigate to="/admin/dashboard" replace />;
// منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند.
if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) {
return <Navigate to="/admin/dashboard" replace />;
@@ -262,9 +298,13 @@ export default function App() {
{/* فاز ۲ — دکتر / کلینیک */}
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
<Route path="my-sessions" element={<RoleRoute roles={['staff']}><StaffTreatmentSessionsPage /></RoleRoute>} />
<Route path="my-sessions/:uuid" element={<RoleRoute roles={['staff']}><StaffSessionDetailPage /></RoleRoute>} />
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
<Route path="record-number-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['patients', 'view']}><RecordNumberSettingsPage /></RoleRoute>} />
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
@@ -272,6 +312,23 @@ export default function App() {
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
<Route path="treatment-cases" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} permission={['treatment', 'view']}><TreatmentCasesPage /></RoleRoute>} />
{/* مجوزش `clinic_info` است نه `appointment_settings`: صفحه حوزهٔ فعالیت را با
PATCH /api/v1/clinic/{uuid} ذخیره میکند و backend همانجا `clinic_info.update`
را میسنجد. گِیتِ قبلی منبع دیگری را میپرسید و با اجبارِ واقعی نمیخواند. */}
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><SkillsPage /></RoleRoute>} />
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcePoolsPage /></RoleRoute>} />
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceDetailPage /></RoleRoute>} />
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینک‌های قدیمی نباید بشکنند. */}
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
{/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا می‌زند. */}
<Route path="national-holidays" element={<RoleRoute roles={['admin']}><NationalHolidaysPage /></RoleRoute>} />
<Route path="holidays" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><HolidaysSettingsPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router';
import { renderWithProviders } from './test/utils';
import { useAuthStore } from './stores/authStore';
import { RoleRoute } from './App';
/**
* گِیتِ نقشیِ صفحات پنل با تمرکز روی پزشکِ عضو در محیط کلینیک.
*
* پیش از این `settingsMenu` و `RoleRoute` دو قاعدهٔ متفاوت داشتند: منو واریانتِ
* `clinic` را به پزشکِ scope=clinic نشان میداد و route با `primaryRole === 'doctor'`
* ردش میکرد. نتیجه یک آیتم منوی مرده بود که به داشبورد میپرید.
*/
function renderGate(node: React.ReactNode) {
return renderWithProviders(
<Routes>
<Route path="/admin/dashboard" element={<div>داشبورد</div>} />
<Route path="/admin/page" element={node} />
</Routes>,
{ route: '/admin/page' },
);
}
function setUser(opts: {
role: string;
scope?: string | null;
permissions?: Record<string, Record<string, boolean>>;
}) {
useAuthStore.setState({
primaryRole: opts.role,
context: {
type: opts.scope === 'clinic' ? 'clinic' : 'doctor',
db_uuid: 'x',
name: 'محیط',
scope: opts.scope ?? null,
permissions: opts.permissions ? { resources: opts.permissions } : null,
},
} as any);
}
describe('RoleRoute', () => {
beforeEach(() => useAuthStore.setState({ primaryRole: null, context: null } as any));
it('نقشِ مجاز عبور می‌کند', () => {
setUser({ role: 'clinic' });
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
expect(screen.getByText('محتوا')).toBeInTheDocument();
});
it('نقشِ غیرمجاز به داشبورد می‌رود', () => {
setUser({ role: 'doctor' });
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
expect(screen.getByText('داشبورد')).toBeInTheDocument();
});
it('پزشکِ عضو با مجوزِ همان صفحه، به واریانتِ clinic راه دارد', () => {
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
renderGate(
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
);
expect(screen.getByText('محتوا')).toBeInTheDocument();
});
it('پزشکِ عضو بدون آن مجوز، راه ندارد', () => {
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: false } } });
renderGate(
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
);
expect(screen.getByText('داشبورد')).toBeInTheDocument();
});
it('صفحهٔ بدون اعلامِ مجوز برای پزشکِ عضو باز نمی‌شود', () => {
// وگرنه هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز می‌شد.
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
expect(screen.getByText('داشبورد')).toBeInTheDocument();
});
it('پزشکِ مطب شخصی از واریانتِ clinic رد می‌شود، حتی با مجوزِ باز', () => {
// محیط شخصی permissions ندارد و `can` آزاد برمی‌گردد؛ scope باید جلویش را بگیرد.
setUser({ role: 'doctor', scope: null });
renderGate(
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
);
expect(screen.getByText('داشبورد')).toBeInTheDocument();
});
});
@@ -16,8 +16,8 @@ const patch = api.patch as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const navigate = vi.fn();
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
+133 -39
View File
@@ -14,9 +14,9 @@ import {
WalletIcon,
} from "@heroicons/react/24/outline";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
@@ -27,6 +27,10 @@ import Modal from "./ui/Modal";
import PersianDateInput from "./ui/PersianDateInput";
import PriceInput from "./ui/PriceInput";
import SearchableSelect from "./ui/SearchableSelect";
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
import Switch from './ui/Switch';
/** Row actions for the appointments table (Figma عملیات menu). */
type ModalKind = null | "info" | "move" | "transfer" | "replace";
@@ -573,26 +577,79 @@ export function TransferReserveModal({
const [date, setDate] = useState(a.appointment_date);
const toReserve = !a.is_reserve;
// روش نوبت‌دهی از محلِ خودِ نوبت، نه محیط جاری پنل.
const { bookingMode, services } = useDoctorBookingServices(
toReserve ? undefined : a.doctor_uuid,
a.clinic_uuid ?? null,
);
const serviceMode = !toReserve && bookingMode === "service";
// بازگشت از رزرو به لیست نوبت‌ها به زمان واقعی نیاز دارد. پیش از این از
// appointment_time/end_time خوانده می‌شد که روی یک رزرو هر دو 00:00 اند — نتیجه،
// نوبتی با مدت صفر در نیمه‌شب بود.
const [pick, setPick] = useState<ServicePick | null>(null);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const initialSelection = useMemo<PickedService[]>(() => {
if (!a.service_items?.length || services.length === 0) return [];
return a.service_items.flatMap((s) => {
const known = services.find((b) => b.uuid === s.uuid);
return known
? [{
uuid: known.uuid,
name: known.name,
section: known.service_section.name,
duration: known.duration_minutes ?? 0,
}]
: [];
});
}, [a.service_items, services]);
const canSubmit = !!date && (toReserve
? true
: serviceMode
? !!pick?.slot && (pick?.serviceUuids.length ?? 0) > 0
: !!start && !!end);
const transfer = useMutation({
mutationFn: () => {
mutationFn: async () => {
const day = toEpoch(date, "00:00");
return api.patch(
`/api/v1/appointment/${a.uuid}`,
toReserve
? // reserve entries are day-level: midnight-to-midnight, no slot occupation
{
is_reserve: true,
slot_start: day,
slot_end: day,
version: a.version,
}
: {
is_reserve: false,
slot_start: toEpoch(date, a.appointment_time),
slot_end: toEpoch(date, a.end_time),
version: a.version,
},
);
if (toReserve) {
// reserve entries are day-level: midnight-to-midnight, no slot occupation
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: true,
slot_start: day,
slot_end: day,
version: a.version,
});
}
if (serviceMode) {
// ابتدا زمان‌دار شود (رزرو زمان ندارد و service-reschedule رزرو را رد می‌کند)،
// سپس مدت و سرویس‌ها با endpoint سرویس‌آگاه تثبیت شوند.
await api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: pick!.slot!.start,
slot_end: pick!.slot!.end,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
version: a.version,
});
return api.post(`/api/v1/appointment/${a.uuid}/service-reschedule`, {
start: pick!.slot!.start,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
});
}
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
version: a.version,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey });
@@ -640,11 +697,60 @@ export function TransferReserveModal({
<div style={{ margin: "6px 0 16px" }}>
<PersianDateInput value={date} onChange={setDate} />
</div>
{/* بازگشت به لیست نوبت‌ها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */}
{!toReserve && serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={a.doctor_uuid}
date={date}
services={services}
clinicUuidOverride={a.clinic_uuid ?? null}
excludeAppointmentUuid={a.uuid}
initialSelection={initialSelection}
onSelect={setPick}
/>
</div>
)}
{!toReserve && !serviceMode && (
<div style={{ display: "flex", gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت شروع
</label>
<div className="field" style={{ marginTop: 6 }}>
<input
aria-label="ساعت شروع"
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
dir="ltr"
/>
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت پایان
</label>
<div className="field" style={{ marginTop: 6 }}>
<input
aria-label="ساعت پایان"
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
dir="ltr"
/>
</div>
</div>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn primary"
style={{ flex: 1 }}
disabled={!date || transfer.isPending}
disabled={!canSubmit || transfer.isPending}
onClick={() => transfer.mutate()}
>
انتقال و حذف از لیست
@@ -904,24 +1010,12 @@ export function ReplaceAppointmentModal({
marginBottom: 12,
}}
>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={depositRequired}
onChange={(e) =>
setDepositRequired(e.target.checked)
}
/>
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<WalletChargeLink mobile={effectiveMobile} />
)}
@@ -7,6 +7,7 @@ import type { Appointment } from '../types';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
import Switch from './ui/Switch';
interface Option { uuid: string; name?: string }
@@ -120,9 +121,13 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{STATUS_OPTIONS.map(([v, l]) => (
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
</label>
<Switch
key={v}
inline
checked={f.statuses.includes(v)}
onChange={() => toggleStatus(v)}
label={l}
/>
))}
</div>
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import AppointmentInvoiceCard from './AppointmentInvoiceCard';
const get = api.get as ReturnType<typeof vi.fn>;
const invoice = {
base_rials: 10_000_000,
items_rials: 2_000_000,
discount_rials: 1_200_000,
insurance_base_rials: 2_160_000,
insurance_supplementary_rials: 0,
tax_rials: 432_000,
final_rials: 9_072_000,
deposit_rials: 1_425_600,
created_at: 1_700_000_000,
breakdown: { discounts: [{ label: 'تخفیف درصدی', rials: 1_200_000 }], sources: {} },
};
describe('AppointmentInvoiceCard', () => {
beforeEach(() => vi.clearAllMocks());
it('lays out the chain down to the final amount', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
expect(screen.getByText('قیمت پایه')).toBeInTheDocument();
expect(screen.getByText('مبلغ نهایی')).toBeInTheDocument();
expect(screen.getByText('بیعانه')).toBeInTheDocument();
});
/** ردیف صفر نباید جا بگیرد — فاکتور شلوغ خوانده نمی‌شود. */
it('hides zero rows', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
expect(screen.queryByText('سهم بیمهٔ تکمیلی')).not.toBeInTheDocument();
});
/** ⭐ نبودِ فاکتور خطا نیست: نوبتِ ثبت‌نهایی‌نشده فاکتوری ندارد. */
it('treats a missing invoice as a normal state', async () => {
get.mockRejectedValue(new Error('not found'));
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() =>
expect(screen.getByText('برای این نوبت فاکتوری ثبت نشده است.')).toBeInTheDocument(),
);
});
it('says the snapshot does not follow later price changes', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() =>
expect(screen.getByText(/تغییر بعدی تعرفه این فاکتور را عوض نمی‌کند/)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,111 @@
import React from 'react';
import { formatDate, formatRial } from '../lib/utils';
import { useAppointmentInvoice } from '../hooks/useAppointmentInvoice';
interface Props {
appointmentUuid: string;
}
/**
* فاکتور تفکیکشدهٔ نوبت.
*
* اعدادش snapshot لحظهٔ ثبتاند، نه محاسبهٔ امروز: تغییر تعرفه هرگز فاکتور صادرشده را
* عوض نمیکند (قانون پنجم مستند). همین جمله زیر کارت هم نوشته میشود، چون کاربری که
* قیمت را دیروز عوض کرده و امروز عدد قدیمی میبیند وگرنه فکر میکند سیستم خراب است.
*/
export default function AppointmentInvoiceCard({ appointmentUuid }: Props) {
const { invoice, loading, missing } = useAppointmentInvoice(appointmentUuid);
if (loading) {
return (
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
در حال بارگذاری فاکتور
</div>
);
}
// نبودِ فاکتور خطا نیست: نوبتی که هنوز ثبت نهایی نشده، فاکتوری هم ندارد.
if (missing || !invoice) {
return (
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
برای این نوبت فاکتوری ثبت نشده است.
</div>
);
}
const rows: { label: string; value: number; muted?: boolean }[] = [
{ label: 'قیمت پایه', value: invoice.base_rials },
{ label: 'آیتم‌های اضافه', value: invoice.items_rials },
{ label: 'تخفیف', value: -invoice.discount_rials },
{ label: 'سهم بیمهٔ پایه', value: -invoice.insurance_base_rials },
{ label: 'سهم بیمهٔ تکمیلی', value: -invoice.insurance_supplementary_rials },
{ label: 'مالیات', value: invoice.tax_rials },
];
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>فاکتور</h3>
{invoice.created_at !== undefined && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
ثبتشده در {formatDate(invoice.created_at)}
</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows
.filter((row) => row.value !== 0)
.map((row) => (
<div
key={row.label}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}
>
<span style={{ color: 'var(--text-2)' }}>{row.label}</span>
<span style={{ color: row.value < 0 ? 'var(--success)' : undefined }}>
{formatRial(Math.abs(row.value))}
{row.value < 0 ? ' ' : ''}
</span>
</div>
))}
{/* فاکتور قدیمی ممکن است ریز تخفیف نداشته باشد؛ نبودنش نباید کل صفحه را ببندد. */}
{(invoice.breakdown?.discounts ?? []).map((line, index) => (
<div
key={index}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-3)' }}
>
<span>{line.label}</span>
<span>{formatRial(Math.abs(line.rials))}</span>
</div>
))}
</div>
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: 10,
display: 'flex',
justifyContent: 'space-between',
fontSize: 15,
fontWeight: 600,
}}
>
<span>مبلغ نهایی</span>
<span>{formatRial(invoice.final_rials)}</span>
</div>
{invoice.deposit_rials > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>بیعانه</span>
<span>{formatRial(invoice.deposit_rials)}</span>
</div>
)}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
قیمتها بر اساس تاریخ همین نوبت محاسبه و ثبت شدهاند؛ تغییر بعدی تعرفه این فاکتور
را عوض نمیکند.
</span>
</div>
);
}
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import AppointmentSegmentsCard from './AppointmentSegmentsCard';
const get = api.get as ReturnType<typeof vi.fn>;
const segment = (over: Record<string, unknown>) => ({
sequence: 1,
name: 'ویزیت',
starts_at: 1_800_000_000,
ends_at: 1_800_001_200,
duration_minutes: 20,
patient_present: true,
...over,
});
describe('AppointmentSegmentsCard', () => {
beforeEach(() => vi.clearAllMocks());
it('sums the recorded segments rather than the service duration', async () => {
get.mockResolvedValue({
data: [
segment({ sequence: 1, duration_minutes: 20 }),
segment({ sequence: 2, name: 'انتظار', duration_minutes: 40, patient_present: false }),
],
});
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
// ارقام فارسی‌اند: ۲۰ + ۴۰ = ۶۰
await waitFor(() => expect(screen.getByText('۶۰ دقیقه')).toBeInTheDocument());
});
/** ⭐ مدتی که بیمار روی صندلی نیست باید دیده شود، وگرنه «۹۰ دقیقه» گمراه‌کننده است. */
it('marks the segments the patient is not present for', async () => {
get.mockResolvedValue({
data: [segment({ sequence: 1, name: 'انتظار', patient_present: false })],
});
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
await waitFor(() => expect(screen.getByText('بدون حضور بیمار')).toBeInTheDocument());
});
/** نوبت اسلاتی بخشی ندارد؛ کارتِ خالی یعنی «چیزی خراب است». */
it('renders nothing for an appointment with no segments', async () => {
get.mockResolvedValue({ data: [] });
const { container } = renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(container.textContent).toBe('');
});
});
@@ -0,0 +1,64 @@
import React from 'react';
import { useAppointmentSegments } from '../hooks/useResourceBooking';
import { formatNumber } from '../lib/utils';
const timeOf = (ts: number) =>
new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
/**
* بخشهای ثبتشدهٔ نوبت همان چیزی که لحظهٔ رزرو تثبیت شد، نه الگوی امروزِ خدمت.
*
* بخشی که بیمار در آن حاضر نیست کمرنگ می‌آید: اپراتوری که «۹۰ دقیقه» را روی نوبت
* میبیند باید بفهمد بیمار همهٔ آن مدت روی صندلی نیست.
*
* نوبت اسلاتی بخشی ندارد و کارت اصلاً رندر نمیشود جدول خالی یعنی چیزی خراب است.
*/
export default function AppointmentSegmentsCard({ appointmentUuid }: { appointmentUuid: string }) {
const { segments, loading } = useAppointmentSegments(appointmentUuid);
if (loading || segments.length === 0) return null;
const total = segments.reduce((sum, s) => sum + s.duration_minutes, 0);
return (
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-[var(--text)]">بخشهای نوبت</h3>
<span className="text-sm text-[var(--text-2)]">{formatNumber(total)} دقیقه</span>
</div>
<div style={{ display: 'flex', gap: 2, marginBottom: 14 }}>
{segments.map((s) => (
<div
key={s.sequence}
title={`${s.name}${formatNumber(s.duration_minutes)} دقیقه`}
style={{
flex: s.duration_minutes,
height: 10,
borderRadius: 'var(--r-pill)',
background: 'var(--primary)',
opacity: s.patient_present ? 1 : 0.35,
}}
/>
))}
</div>
<ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{segments.map((s) => (
<li key={s.sequence} style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-3)', minWidth: 18 }}>{formatNumber(s.sequence)}</span>
<span style={{ flex: 1 }}>{s.name}</span>
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
{timeOf(s.starts_at)} {timeOf(s.ends_at)}
</span>
{!s.patient_present && (
<span className="badge" style={{ fontSize: 11 }}>
بدون حضور بیمار
</span>
)}
</li>
))}
</ol>
</div>
);
}
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import ConfirmDialog from './ui/ConfirmDialog';
import { useCancelAppointment } from '../hooks/useCancellation';
interface Props {
open: boolean;
appointmentUuid: string;
/** چه کسی لغو می‌کند — وضعیت نهایی نوبت از همین می‌آید. */
by?: 'user' | 'doctor';
onClose: () => void;
onCancelled?: () => void;
}
/** لغو نوبت با تأیید و دلیل اختیاری. */
export default function CancelAppointmentDialog({
open,
appointmentUuid,
by = 'doctor',
onClose,
onCancelled,
}: Props) {
const [reason, setReason] = useState('');
const cancel = useCancelAppointment();
const close = () => {
setReason('');
onClose();
};
return (
<ConfirmDialog
open={open}
title="لغو نوبت"
message="آیا از لغو این نوبت اطمینان دارید؟"
confirmLabel="لغو نوبت"
danger
loading={cancel.isPending}
onConfirm={() =>
cancel.mutate(
{ uuid: appointmentUuid, by, reason },
{
onSuccess: () => {
close();
onCancelled?.();
},
},
)
}
onCancel={close}
>
<div style={{ marginTop: 14 }}>
<label className="cp-label mb-2" htmlFor="cancel-reason">
دلیل لغو (اختیاری)
</label>
<textarea
id="cancel-reason"
className="cp-input"
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="دلیل لغو نوبت را وارد کنید..."
style={{ width: '100%', resize: 'vertical' }}
/>
</div>
</ConfirmDialog>
);
}
@@ -1,48 +1,129 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import ClinicDoctorsManager from './ClinicDoctorsManager';
const get = api.get as ReturnType<typeof vi.fn>;
vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
] });
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
responded_at: null, doctor: null },
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
const DOCTOR = {
id: '1',
uuid: 'doc-1',
name: 'پزشک دعوت‌شده',
gender: null,
degree: null,
img: [],
specialties: [],
active: false, // نوبت‌دهی آنلاینِ خودِ پزشک خاموش است
};
/** پاسخ‌ها بر اساس مسیر تفکیک می‌شوند، چون سه کوئری موازی می‌روند. */
function mockApi({ doctors = [DOCTOR], total = 1, permissions = [] as any[], failDoctors = false } = {}) {
(api.get as any).mockImplementation((url: string) => {
if (url.includes('/doctor-permissions')) return Promise.resolve({ data: permissions });
if (url.includes('/invitations')) return Promise.resolve({ data: [] });
if (url.includes('/doctor-list/')) {
return failDoctors
? Promise.reject(new Error('boom'))
: Promise.resolve({ data: doctors, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } });
}
return Promise.resolve({ data: [] });
});
});
}
describe('ClinicDoctorsManager', () => {
it('lists clinic doctors and shows management controls by default', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('قلب')).toBeInTheDocument();
// manager controls
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
beforeEach(() => vi.clearAllMocks());
it('صفحه و سقف را به سرور می‌فرستد — وگرنه backend سر ۱۰ پزشک بی‌صدا می‌برد', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
const listCall = (api.get as any).mock.calls.find((c: any[]) => c[0].includes('/doctor-list/'));
expect(listCall[0]).toContain('page=1');
expect(listCall[0]).toContain('limit=10');
});
it('hides every mutating control when readOnly', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
it('شمارندهٔ تب از meta می‌آید، نه از طول صفحهٔ جاری', async () => {
mockApi({ doctors: [DOCTOR], total: 25 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
});
it('«نوبت‌دهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('دسترسی در کلینیک')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی آنلاین')).toBeInTheDocument();
});
it('شمار مجوزها زیر همان بجِ دسترسی می‌آید، نه در ستون جدا', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
});
it('پزشکِ بدون ردیف مجوز، «پیش‌فرض» و دسترسی فعال می‌گیرد', async () => {
mockApi({ permissions: [] });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('پیش‌فرض')).toBeInTheDocument();
});
it('ردیف مجوزِ خاموش، «غیرفعال» نشان می‌دهد و شمار مجوزها را می‌آورد', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: false,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
// دو «غیرفعال»: یکی دسترسیِ کلینیک، یکی نوبت‌دهی آنلاین. پیش از این یک بج جای
// هر دو می‌نشست و همین ابهام، پزشکِ سالم را «غیرفعال» نشان می‌داد.
expect(screen.getAllByText('غیرفعال')).toHaveLength(2);
});
it('دسترسیِ فعال با نوبت‌دهیِ خاموش قاطی نمی‌شود', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
// دسترسی فعال است…
expect(await screen.findByText('فعال')).toBeInTheDocument();
// …ولی نوبت‌دهی آنلاینِ خودِ پزشک همچنان خاموش.
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
});
it('خطای فهرست، پیام خطا می‌دهد نه حالت خالی', async () => {
mockApi({ failDoctors: true });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
});
it('فهرست خالی، پیام خالی می‌دهد', async () => {
mockApi({ doctors: [], total: 0 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
});
});
+202 -54
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
@@ -11,8 +11,11 @@ import { formatNumber } from '../lib/utils';
import ConfirmDialog from './ui/ConfirmDialog';
import InviteDoctorModal from './ui/InviteDoctorModal';
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
import DataTable, { type Column } from './ui/DataTable';
import Pagination from './ui/Pagination';
const HUES_LIST = [256, 205, 162, 295, 272];
const PAGE_SIZE = 10;
export interface ClinicDoctorItem {
id: string; uuid: string; name: string;
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
doctor: { uuid: string; name: string } | null;
}
/** یک ردیف از `GET /api/v1/admin/clinic/{uuid}/doctor-permissions`. */
interface DoctorPermissionRow {
doctor_uuid: string;
active: boolean;
permissions: { resources: Record<string, Record<string, boolean>> };
}
/** چند اکشن از کل اکشن‌های تعریف‌شده به این پزشک داده شده. */
function grantedCount(row?: DoctorPermissionRow): { granted: number; total: number } {
const resources = row?.permissions?.resources ?? {};
let granted = 0;
let total = 0;
for (const actions of Object.values(resources)) {
for (const allowed of Object.values(actions)) {
total += 1;
if (allowed) granted += 1;
}
}
return { granted, total };
}
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
const [inviteOpen, setInviteOpen] = useState(false);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// فیلدِ جستجو محلی می‌ماند و فقط مقدارِ آرام‌شده به کوئری می‌رود؛ وگرنه هر حرف یک
// درخواست به سرور می‌زند.
useEffect(() => {
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 350);
return () => clearTimeout(t);
}, [search]);
/**
* صفحه و جستجو به سرور میروند.
*
* پیش از این هیچکدام فرستاده نمیشد و backend سقف پیشفرضِ ۱۰ را اعمال میکرد
* (`DoctorRepository::findByClinicWithFilters`)، پس کلینیکِ یازدهپزشکه بیهیچ نشانهای
* یک پزشک را گم میکرد.
*/
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch],
queryFn: () => api.get<PaginatedResponse<ClinicDoctorItem>>(
`/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}`
+ (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''),
),
enabled: !!clinicUuid,
});
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
enabled: !!clinicUuid,
});
/**
* مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست.
*
* اندپوینت تکپزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل
* وجود داشت و فقط مصرف نمیشد.
*/
const permissionsQ = useQuery({
queryKey: ['clinic-doctor-permissions', clinicUuid],
queryFn: () => api.get<ApiResponse<DoctorPermissionRow[]>>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`),
enabled: !!clinicUuid && canUpdate,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const doctorTotal = doctorsQ.data?.meta?.totalRecords ?? doctorList.length;
const permissionByDoctor = useMemo(() => {
const rows = permissionsQ.data?.data ?? [];
return new Map((Array.isArray(rows) ? rows : []).map(r => [r.doctor_uuid, r]));
}, [permissionsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const resendInvMut = useMutation({
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
onError: (e: Error) => toast.error(e.message),
});
/**
* ستونها دو «فعال بودن» را از هم جدا میکنند، چون دو چیز متفاوتاند و پیش از این
* یکی جای هر دو مینشست:
*
* «دسترسی در کلینیک» `ClinicDoctorPermission::active` کلیدِ خودِ مالک کلینیک،
* و خاموشبودنش یعنی `can()` همهچیز را رد میکند.
* «نوبتدهی آنلاین» `Doctor::$activeDoctorAppointment && has_schedule` حالِ
* پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد.
*
* بجِ قبلی دومی را نشان میداد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ
* هفتگی «غیرفعال» خوانده میشد و مالک ممکن بود بیدلیل جدایش کند.
*/
const doctorColumns: Column<ClinicDoctorItem>[] = useMemo(() => [
{
key: 'name',
header: 'پزشک',
render: (doc) => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
</div>
);
},
},
{
// بج و شمارِ مجوز یک ستون‌اند نه دو: هر دو یک سؤال را جواب می‌دهند («این پزشک
// چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا می‌شود.
key: 'clinic_access',
header: 'دسترسی در کلینیک',
render: (doc) => {
// ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیش‌فرض‌ها برقرارند.
const row = permissionByDoctor.get(doc.uuid);
const on = row?.active ?? true;
const { granted, total } = grantedCount(row);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
<span className={`badge ${on ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{on ? 'فعال' : 'غیرفعال'}
</span>
<span className="muted" style={{ fontSize: 11 }}>
{row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیش‌فرض'}
</span>
</div>
);
},
},
{
key: 'online_booking',
header: 'نوبت‌دهی آنلاین',
render: (doc) => (
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
),
},
], [permissionByDoctor]);
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
@@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
{/* شمارنده از meta می‌آید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
پزشکان ({formatNumber(doctorTotal)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامهها ({formatNumber(invitationList.length)})
@@ -147,67 +282,76 @@ export default function ClinicDoctorsManager({
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
doctorList.length === 0 ? (
doctorsQ.isError ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
<p className="muted">فهرست پزشکان بارگذاری نشد</p>
<button className="btn secondary sm" onClick={() => doctorsQ.refetch()}>تلاش دوباره</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{doctorList.map(doc => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
<>
<DataTable<ClinicDoctorItem>
columns={doctorColumns}
data={doctorList}
loading={doctorsQ.isLoading}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="جستجوی نام پزشک"
emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'}
actions={doc => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className="mini-btn"
title="مشاهده پروفایل"
aria-label={`مشاهده پروفایل ${doc.name}`}
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title=شاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
title=دیریت دسترسی‌ها"
aria-label={`مدیریت دسترسی‌های ${doc.name}`}
onClick={() => setPermissionsFor(doc)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
aria-label={`جداسازی ${doc.name} از کلینیک`}
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
);
})}
</div>
)}
/>
<Pagination
page={page}
total={doctorTotal}
limit={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
invitationsQ.isLoading ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">در حال بارگذاری</p>
</div>
) : invitationsQ.isError ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">فهرست دعوتنامهها بارگذاری نشد</p>
<button className="btn secondary sm" onClick={() => invitationsQ.refetch()}>تلاش دوباره</button>
</div>
) : invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({
clinicUuid={clinicUuid}
doctorUuid={permissionsFor.uuid}
doctorName={permissionsFor.name}
onClose={() => setPermissionsFor(null)}
onClose={() => {
setPermissionsFor(null);
// ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند.
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] });
}}
/>
)}
+3 -6
View File
@@ -13,6 +13,7 @@ import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import Switch from './ui/Switch';
const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
@@ -333,12 +334,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
</label>
<Switch inline checked={f.combinable} onChange={(v) => set('combinable', v)} label="قابل ترکیب با سایر تخفیف‌ها" />
<Switch inline checked={f.active} onChange={(v) => set('active', v)} label="فعال" />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
import FieldSchemaEditor from './FieldSchemaEditor';
import type { TreatmentFormField } from '../types';
const selectField = (options?: TreatmentFormField['options']): TreatmentFormField => ({
key: 'spot',
label: 'اسپات',
type: 'select',
required: false,
sort_order: 0,
options,
});
/** والدِ کنترل‌شده — همان قراردادی که مودال نوع منبع دارد. */
function Harness({ initial, onEmit }: {
initial: TreatmentFormField[];
onEmit?: (fields: TreatmentFormField[]) => void;
}) {
const [value, setValue] = useState(initial);
return (
<FieldSchemaEditor
value={value}
onChange={(next) => { setValue(next); onEmit?.(next); }}
/>
);
}
const optionsInput = () =>
screen.getByPlaceholderText('7, 8, 9, 10, 12, 14, 16, 18') as HTMLInputElement;
describe('FieldSchemaEditor — ورودی گزینه‌های فیلد select', () => {
it('کاما در فیلد باقی می‌ماند و پاک نمی‌شود', () => {
renderWithProviders(<Harness initial={[selectField(['7'])]} />);
fireEvent.change(optionsInput(), { target: { value: '7,' } });
expect(optionsInput().value).toBe('7,');
});
it('فاصلهٔ بعد از کاما حفظ می‌شود و گزینهٔ بعدی تایپ‌شدنی است', () => {
const onEmit = vi.fn();
renderWithProviders(<Harness initial={[selectField(['7'])]} onEmit={onEmit} />);
fireEvent.change(optionsInput(), { target: { value: '7, ' } });
expect(optionsInput().value).toBe('7, ');
fireEvent.change(optionsInput(), { target: { value: '7, 8' } });
expect(optionsInput().value).toBe('7, 8');
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]);
});
it('کامای فارسی هم جداکننده است', () => {
const onEmit = vi.fn();
renderWithProviders(<Harness initial={[selectField([])]} onEmit={onEmit} />);
fireEvent.change(optionsInput(), { target: { value: 'کم، زیاد' } });
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['کم', 'زیاد'] })]);
});
it('مقدار اولیه از آرایه ساخته می‌شود', () => {
renderWithProviders(<Harness initial={[selectField(['7', '8', '9'])]} />);
expect(optionsInput().value).toBe('7, 8, 9');
});
it('گزینهٔ خالی به بک‌اند فرستاده نمی‌شود', () => {
const onEmit = vi.fn();
renderWithProviders(<Harness initial={[selectField([])]} onEmit={onEmit} />);
fireEvent.change(optionsInput(), { target: { value: '7,, ,8,' } });
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]);
});
});
@@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Field from './ui/Field';
import Input from './ui/Input';
import Switch from './ui/Switch';
import SearchableSelect from './ui/SearchableSelect';
import type { TreatmentFormField } from '../types';
const TYPE_OPTIONS = [
{ value: 'select', label: 'انتخاب از فهرست' },
{ value: 'number', label: 'عدد' },
{ value: 'text', label: 'متن' },
];
const MAX_FIELDS = 20;
const parseOptions = (raw: string) =>
raw.split(/[,،]/).map((o) => o.trim()).filter((o) => o !== '');
/**
* ورودی متنی گزینههای فیلد `select`.
*
* متن خام را در state خودش نگه میدارد و آرایه را فقط موقع emit میسازد. اگر مقدار
* input مستقیم از آرایه ساخته میشد، کاما و فاصلهٔ انتهایی در همان keystroke حذف
* میشد (چون عنصر خالی filter میشود) و کاربر اصلاً نمیتوانست کاما تایپ کند.
*/
function OptionsInput({ id, options, disabled, onChange }: {
id: string;
options: TreatmentFormField['options'];
disabled?: boolean;
onChange: (options: string[]) => void;
}) {
const [draft, setDraft] = useState(() => (options ?? []).join(', '));
const emitted = useRef(options);
// فقط وقتی آرایه از بیرون عوض شود (نه با تایپ خودِ کاربر) متن را همگام کن.
useEffect(() => {
if (options !== emitted.current) {
emitted.current = options;
setDraft((options ?? []).join(', '));
}
}, [options]);
return (
<Input
id={id}
value={draft}
disabled={disabled}
dir="ltr"
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
onChange={(e) => {
setDraft(e.target.value);
const next = parseOptions(e.target.value);
emitted.current = next;
onChange(next);
}}
/>
);
}
/**
* فرمی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع پر میکند.
*
* روی نوع منبع تعریف میشود نه روی سرویس، چون خودِ دستگاه تعیین میکند چه چیزی
* خواندنی است: لیزر انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. افزودن دستگاه
* تازه اینطور تنظیمات است، نه تغییر کد.
*/
export default function FieldSchemaEditor({ value, onChange, disabled }: {
value: TreatmentFormField[];
onChange: (fields: TreatmentFormField[]) => void;
disabled?: boolean;
}) {
const patch = (index: number, changes: Partial<TreatmentFormField>) => {
onChange(value.map((f, i) => (i === index ? { ...f, ...changes } : f)));
};
const add = () => {
onChange([...value, { key: '', label: '', type: 'number', required: false, sort_order: value.length }]);
};
return (
<div style={{ display: 'grid', gap: 12 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
اپراتور بعد از درمان هر ناحیه این فیلدها را پر میکند. بدون فیلد، فقط دستگاه و زمان ثبت میشود.
</p>
{value.map((field, index) => (
<div key={index} className="card card-pad" style={{ display: 'grid', gap: 10, background: 'var(--surface-2)' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Field label="کلید (انگلیسی)" htmlFor={`fs-key-${index}`}>
<Input
id={`fs-key-${index}`}
value={field.key}
disabled={disabled}
dir="ltr"
placeholder="energy"
onChange={(e) => patch(index, { key: e.target.value })}
/>
</Field>
<Field label="برچسب فارسی" htmlFor={`fs-label-${index}`}>
<Input
id={`fs-label-${index}`}
value={field.label}
disabled={disabled}
placeholder="انرژی"
onChange={(e) => patch(index, { label: e.target.value })}
/>
</Field>
<Field label="نوع" htmlFor={`fs-type-${index}`}>
<SearchableSelect
inputId={`fs-type-${index}`}
options={TYPE_OPTIONS}
value={field.type}
isDisabled={disabled}
onChange={(v) => patch(index, { type: (v === null ? 'text' : String(v)) as TreatmentFormField['type'] })}
ariaLabel="نوع فیلد"
/>
</Field>
</div>
{field.type === 'select' && (
<Field label="گزینه‌ها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
<OptionsInput
id={`fs-options-${index}`}
options={field.options}
disabled={disabled}
onChange={(options) => patch(index, { options })}
/>
</Field>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<Switch
inline
checked={field.required ?? false}
onChange={(v) => patch(index, { required: v })}
disabled={disabled}
label="الزامی"
/>
{!disabled && (
<button
type="button"
className="btn ghost sm"
onClick={() => onChange(value.filter((_, i) => i !== index))}
aria-label={`حذف فیلد ${field.label || index + 1}`}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</div>
</div>
))}
{!disabled && value.length < MAX_FIELDS && (
<button type="button" className="btn secondary sm" onClick={add} style={{ justifySelf: 'start' }}>
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن فیلد
</button>
)}
</div>
);
}
@@ -28,7 +28,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
// PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛
// انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد.
await waitFor(() => expect(get).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.click(screen.getByText('ذخیره'));
@@ -40,7 +43,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
// PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛
// انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد.
await waitFor(() => expect(get).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
@@ -55,7 +61,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
// PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛
// انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد.
await waitFor(() => expect(get).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.click(screen.getByText('ذخیره'));
@@ -71,6 +80,8 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
expect(screen.getByRole('textbox')).toHaveValue('50000');
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(
new Intl.NumberFormat('fa-IR').format(50_000),
));
});
});
+15 -22
View File
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
import PriceInput from './ui/PriceInput';
import Switch from './ui/Switch';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
@@ -62,10 +63,11 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
<label style={{ fontSize: 11.5, fontWeight: 600 }}>
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<input
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
aria-invalid={!!error}
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
<PriceInput
className="input"
style={{ width: 200 }}
value={value === '' ? '' : Number(value)}
onChange={(v) => { setValue(String(v)); setError(''); }}
/>
</div>
{value !== '' && (
@@ -76,23 +78,14 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: required ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
}}>
<input
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
الزامی کردن هزینه ویزیت
</label>
<div style={{ marginTop: 16 }}>
<Switch
inline
checked={required}
onChange={(v) => { setRequired(v); setError(''); }}
label="الزامی کردن هزینه ویزیت"
/>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { usePermissions } from '../hooks/usePermissions';
import Switch from './ui/Switch';
interface ServiceCategoryRow {
key: string;
@@ -70,22 +71,14 @@ export default function InsuranceServiceCategoriesCard() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
{rows.map((row) => (
<label
<Switch
key={row.key}
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
>
<span className="switch">
<input
type="checkbox"
aria-label={row.label}
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>{row.label}</span>
</label>
inline
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
label={row.label}
/>
))}
</div>
</div>
@@ -8,6 +8,7 @@ vi.mock('../lib/api', () => ({
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import InvoiceSummaryModal from './InvoiceSummaryModal';
const get = api.get as ReturnType<typeof vi.fn>;
@@ -121,6 +122,61 @@ describe('InvoiceSummaryModal', () => {
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
});
/**
* ستون «ثبتکننده»: نامِ حلشدهٔ سرور، و لینک فقط وقتی بیننده صفحهٔ آن پروفایل را
* میتواند باز کند. عکسِ لحظهٔ ثبت (`created_by_name`) فقط fallback است.
*/
describe('ثبت‌کنندهٔ پرداخت', () => {
const withRecorder = (recorder: object | null) => ({
...baseInvoice,
session: {
...fullSession,
payments: [{
uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000,
created_by_name: '09120000000', created_by: recorder,
}],
},
});
it('پزشکِ ثبت‌کننده به پروفایل پزشک لینک می‌شود', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder({ user_uuid: 'u1', name: 'دکتر رضایی', role: 'doctor', doctor_uuid: 'doc-9' }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
const link = await screen.findByRole('link', { name: 'دکتر رضایی' });
expect(link).toHaveAttribute('href', '/admin/doctors/doc-9');
// نامِ زنده جای شمارهٔ ذخیره‌شده می‌نشیند.
expect(screen.queryByText('09120000000')).toBeNull();
});
it('منشیِ ثبت‌کننده برای کلینیک به فهرست منشی‌های خودش لینک می‌شود', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
const link = await screen.findByRole('link', { name: 'منشی مدیسا' });
expect(link).toHaveAttribute('href', '/admin/my-secretaries');
});
it('بیننده‌ای که آن صفحه را ندارد، فقط نام می‌بیند نه لینک', async () => {
useAuthStore.setState({ primaryRole: 'secretary' } as any);
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
expect(await screen.findByText('منشی مدیسا')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'منشی مدیسا' })).toBeNull();
});
it('بدون created_by (کاربر حذف‌شده): همان عکسِ ذخیره‌شده، بدون لینک', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder(null));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
expect(await screen.findByText('09120000000')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: '09120000000' })).toBeNull();
});
});
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
mockInvoice(baseInvoice);
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
@@ -1,12 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import Modal from './ui/Modal';
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
import { METHOD_LABELS } from './session/PaymentStep';
interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
/** ثبت‌کنندهٔ پرداخت — نامش زنده از خودِ کاربر حل می‌شود، نه از عکسِ لحظهٔ ثبت. */
interface PaymentRecorder {
user_uuid: string;
name: string | null;
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user';
doctor_uuid: string | null;
}
interface SessionPayment {
uuid: string; method: string; amount_rials: number; paid_at: number;
created_by_name: string | null;
created_by?: PaymentRecorder | null;
}
interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
interface SessionData {
session_at: number | null; paid_at: number | null;
@@ -60,8 +73,47 @@ function SectionTable({ title, cols, rows }: { title: string; cols: string[]; ro
);
}
/**
* پروفایلِ ثبتکننده در پنلِ همین بیننده یا `null` وقتی صفحهای برایش وجود ندارد.
*
* فقط پزشک صفحهٔ پروفایلِ مستقل دارد؛ منشی و پرسنل صفحهٔ فهرستِ مدیریتشان را دارند
* و ادمین فهرستِ خودش را. مسیری که نقشِ بیننده اجازهاش را ندارد لینک نمیشود، وگرنه
* کلیک به داشبورد پرت میکرد.
*/
function recorderProfilePath(recorder: PaymentRecorder, viewerRole: string | null): string | null {
if (recorder.role === 'doctor' && recorder.doctor_uuid) {
return ['admin', 'doctor', 'clinic', 'representation'].includes(viewerRole ?? '')
? `/admin/doctors/${recorder.doctor_uuid}`
: null;
}
if (recorder.role === 'secretary') {
if (viewerRole === 'admin') return '/admin/secretaries';
return viewerRole === 'clinic' || viewerRole === 'doctor' ? '/admin/my-secretaries' : null;
}
if (recorder.role === 'staff') {
return ['clinic', 'doctor', 'secretary'].includes(viewerRole ?? '') ? '/admin/staff' : null;
}
if (viewerRole === 'admin') return `/admin/users/${recorder.user_uuid}`;
return null;
}
/** سلولِ «ثبت‌کننده»: نام، و اگر پروفایلی در دسترسِ بیننده باشد، لینکش. */
function RecorderCell({ payment, viewerRole }: { payment: SessionPayment; viewerRole: string | null }) {
const recorder = payment.created_by ?? null;
const name = recorder?.name ?? payment.created_by_name;
if (!name) return <>-</>;
const path = recorder ? recorderProfilePath(recorder, viewerRole) : null;
return path
? <Link to={path} style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{name}</Link>
: <>{name}</>;
}
/** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */
export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) {
const viewerRole = useAuthStore(s => s.primaryRole);
const { data, isLoading } = useQuery<ApiResponse<any>>({
queryKey: ['invoice', invoiceUuid],
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
@@ -156,7 +208,7 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
METHOD_LABELS[p.method] ?? p.method,
formatRial(p.amount_rials),
p.paid_at ? formatDateTime(p.paid_at) : '-',
p.created_by_name ?? '-',
<RecorderCell payment={p} viewerRole={viewerRole} />,
]),
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
]
@@ -11,6 +11,7 @@ import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
import Switch from './ui/Switch';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
@@ -342,10 +343,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
<>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
</div>
{depositRequired && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
+40 -3
View File
@@ -1,4 +1,4 @@
import { formatDate } from '../lib/utils';
import { formatDate, formatNumber } from '../lib/utils';
import BackButton from './ui/BackButton';
import {
ArrowLeftD, FilesServicePhone, FilesServiceCalendar,
@@ -44,14 +44,23 @@ const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string
* FileServicesHeader (name + status chip, file number, tags, contact/date,
* next appointment, یادداشت button).
*/
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, onAddNote }: {
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, nextSession, hasDebt, noShows, onAddNote }: {
name: string;
recordNumber?: string | null;
mobile?: string | null;
createdAt?: number;
tags?: Tag[];
nextAppointment?: number | null;
/**
* جلسهٔ بعدیِ دوره وقتی نوبتی رزرو نشده.
*
* جدا از `nextAppointment` است و باید هم باشد: این هنوز نوبت نیست، برنامه است.
* یکی کردنشان یعنی بنر چیزی را «نوبت» بنامد که کسی رزروش نکرده.
*/
nextSession?: number | null;
hasDebt?: boolean;
/** خلاصهٔ عدم حضور در پنجرهٔ سیاست — `null` یعنی هنوز نیامده. */
noShows?: { count: number; threshold: number; window_days: number; at_risk: boolean } | null;
onAddNote: () => void;
}) {
const complete = !hasDebt;
@@ -74,6 +83,26 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA
<span style={{ fontSize: 16, color: 'var(--text-2)' }}>برچسب ها:</span>
<TagDots tags={tags} />
</div>
{/* شمار عدم حضور فقط وقتی میآید که واقعاً اتفاقی افتاده باشد. «۰ غیبت» روی
پروندهٔ هر بیمار سالم، اتهام بیجاست. عبور از آستانه فقط رنگش را عوض میکند
مسدودسازی کارِ قانون `eligibility` است، نه این نشان. */}
{noShows && noShows.count > 0 && (
<span
title={`در ${formatNumber(noShows.window_days)} روز گذشته · آستانهٔ سیاست: ${formatNumber(noShows.threshold)}`}
style={{
alignSelf: 'flex-start',
fontSize: 13,
borderRadius: 8,
padding: '4px 10px',
background: noShows.at_risk ? 'var(--danger-bg)' : 'var(--warning-bg)',
color: noShows.at_risk ? 'var(--danger)' : 'var(--warning)',
}}
>
{formatNumber(noShows.count)} بار عدم حضور
{noShows.at_risk && ' — پرریسک'}
</span>
)}
</div>
{/* middle — contact + file date */}
@@ -84,7 +113,15 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA
{/* left — next appointment + note */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 28 }}>
<InfoLine icon={<FilesServiceNotification />} label="نوبت بعدی:" value={nextAppointment ? formatDate(nextAppointment) : '—'} />
<InfoLine
icon={<FilesServiceNotification />}
label={nextAppointment || !nextSession ? 'نوبت بعدی:' : 'جلسهٔ بعدی:'}
value={
nextAppointment ? formatDate(nextAppointment)
: nextSession ? formatDate(nextSession)
: '—'
}
/>
<button
type="button" onClick={onAddNote}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--accent)', color: 'var(--on-primary)', border: 'none', borderRadius: 10, padding: '0 18px', height: 36, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
@@ -5,6 +5,7 @@ import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import SearchableSelect from './ui/SearchableSelect';
import Switch from './ui/Switch';
export interface PatientFilters {
gender?: string; // male | female
@@ -111,11 +112,12 @@ export default function PatientsFilterModal({ open, onClose, value, onApply }: {
<div>
<label style={label}>وضعیت پرونده</label>
<label className="switch" title="فقط پرونده‌های دارای بدهی" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" checked={!!f.has_debt} onChange={(e) => set('has_debt', e.target.checked)} aria-label="فقط پرونده‌های دارای بدهی" />
<span className="switch-track"><span className="switch-thumb" /></span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>فقط پروندههای دارای بدهی</span>
</label>
<Switch
inline
checked={!!f.has_debt}
onChange={(v) => set('has_debt', v)}
label="فقط پرونده‌های دارای بدهی"
/>
</div>
<div>
@@ -0,0 +1,156 @@
import React from 'react';
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
import type { PolicyCategorySchema, PolicyClause } from '../types';
const OPERATOR_LABELS: Record<string, string> = {
equals: 'برابر است با',
not_equals: 'برابر نیست با',
greater_than: 'بیشتر از',
less_than: 'کمتر از',
in: 'یکی از',
contains: 'شامل',
};
interface Props {
schema: PolicyCategorySchema;
match: 'all' | 'any';
clauses: PolicyClause[];
onChange: (match: 'all' | 'any', clauses: PolicyClause[]) => void;
}
/**
* شرطساز کاملاً از `policy-schema` ساخته میشود.
*
* فیلدها، عملگرهای **مجاز برای همان فیلد**، و نوع ورودی مقدار، همه از سرور میآیند.
* اگر اینجا فهرست دستی مینوشتیم، هر فیلد تازه در بکاند نیاز به تغییر فرانت داشت و
* بعد از دو ماه دو فهرست ناهمگام میداشتیم.
*/
export default function PolicyConditionBuilder({ schema, match, clauses, onChange }: Props) {
const metaOf = (field: string) => schema.field_meta.find((m) => m.key === field);
const update = (index: number, patch: Partial<PolicyClause>) => {
onChange(
match,
clauses.map((c, i) => (i === index ? { ...c, ...patch } : c)),
);
};
const addClause = () => {
const first = schema.field_meta[0];
if (!first) return;
onChange(match, [...clauses, { field: first.key, operator: first.operators[0], value: '' }]);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>شرطها با هم:</span>
<div style={{ minWidth: 160 }}>
<SearchableSelect
value={match}
onChange={(v) => onChange((v as 'all' | 'any') ?? 'all', clauses)}
options={[
{ value: 'all', label: 'همه برقرار باشند' },
{ value: 'any', label: 'یکی برقرار باشد' },
]}
/>
</div>
</div>
{clauses.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
بدون شرط، این قانون روی همهٔ نوبتهای دامنهاش اعمال میشود.
</p>
)}
{clauses.map((clause, index) => {
const meta = metaOf(clause.field);
return (
<div
key={index}
style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
>
<div style={{ minWidth: 200 }}>
<SearchableSelect
value={clause.field}
onChange={(v) => {
const next = metaOf(String(v ?? ''));
update(index, {
field: String(v ?? ''),
// عملگر قبلی ممکن است برای فیلد تازه بی‌معنا باشد؛ به اولین
// عملگرِ مجاز برمی‌گردد تا کاربر ۴۲۲ نگیرد.
operator: next?.operators[0] ?? 'equals',
value: '',
});
}}
options={schema.field_meta.map((m) => ({ value: m.key, label: m.label }))}
/>
</div>
<div style={{ minWidth: 160 }}>
<SearchableSelect
value={clause.operator}
onChange={(v) => update(index, { operator: String(v ?? 'equals') })}
options={(meta?.operators ?? []).map((op) => ({
value: op,
label: OPERATOR_LABELS[op] ?? op,
}))}
/>
</div>
<div style={{ minWidth: 160, flex: 1 }}>
{meta?.type === 'enum' ? (
<SearchableSelect
value={String(clause.value ?? '')}
onChange={(v) => update(index, { value: String(v ?? '') })}
options={(meta.values ?? []).map((val) => ({
value: val,
label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val,
}))}
/>
) : meta?.type === 'bool' ? (
<SearchableSelect
value={String(clause.value ?? '')}
onChange={(v) => update(index, { value: v === 'true' })}
options={[
{ value: 'true', label: 'بله' },
{ value: 'false', label: 'خیر' },
]}
/>
) : (
<input
className="input"
type={meta?.type === 'int' ? 'number' : 'text'}
value={String(clause.value ?? '')}
onChange={(e) =>
update(index, {
value: meta?.type === 'int' ? Number(e.target.value) : e.target.value,
})
}
placeholder="مقدار"
/>
)}
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => onChange(match, clauses.filter((_, i) => i !== index))}
aria-label="حذف شرط"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
);
})}
<div>
<button type="button" className="btn secondary sm" onClick={addClause}>
<PlusIcon style={{ width: 15 }} /> افزودن شرط
</button>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import PolicyVersionDiff from './PolicyVersionDiff';
const version = (n: number, snapshot: Record<string, unknown>) => ({
version: n,
created_at: 1_800_000_000 + n,
snapshot,
});
describe('PolicyVersionDiff', () => {
/** ⭐ سؤال واقعی «چه چیزی عوض شد؟» است، نه «هر نسخه چه بود». */
it('shows only the fields that changed between versions', () => {
render(
<PolicyVersionDiff
versions={[
version(1, { name: 'تخفیف پاییز', priority: 0, effects: [{ type: 'discount_percent', value: 10 }] }),
version(2, { name: 'تخفیف پاییز', priority: 5, effects: [{ type: 'discount_percent', value: 20 }] }),
]}
/>,
);
expect(screen.getByText('اولویت')).toBeInTheDocument();
expect(screen.getByText('اثرها')).toBeInTheDocument();
// نام عوض نشده، پس نباید ردیف بگیرد.
expect(screen.queryByText('نام')).toBeNull();
});
it('marks the first version rather than diffing it against nothing', () => {
render(<PolicyVersionDiff versions={[version(1, { name: 'قانون' })]} />);
expect(screen.getByText('نسخهٔ نخست')).toBeInTheDocument();
});
it('says so when a version changed nothing meaningful', () => {
render(
<PolicyVersionDiff
versions={[version(1, { name: 'قانون', priority: 0 }), version(2, { name: 'قانون', priority: 0 })]}
/>,
);
expect(screen.getByText('بدون تغییرِ معنادار')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
import React from 'react';
import { formatDate } from '../lib/utils';
interface VersionRow {
version: number;
created_at: number;
snapshot: Record<string, unknown>;
}
/** فقط فیلدهایی که تغییرشان معنا دارد — `updated_at` و نسخه خودشان همیشه فرق دارند. */
const WATCHED: Array<{ key: string; label: string }> = [
{ key: 'name', label: 'نام' },
{ key: 'priority', label: 'اولویت' },
{ key: 'active', label: 'فعال' },
{ key: 'condition', label: 'شرط' },
{ key: 'effects', label: 'اثرها' },
{ key: 'valid_from', label: 'شروع اعتبار' },
{ key: 'valid_to', label: 'پایان اعتبار' },
{ key: 'service_uuid', label: 'سرویس' },
{ key: 'address_uuid', label: 'شعبه' },
];
function show(value: unknown): string {
if (value === null || value === undefined) return '—';
if (typeof value === 'boolean') return value ? 'بله' : 'خیر';
if (typeof value === 'object') return JSON.stringify(value, null, 0);
return String(value);
}
/**
* تفاوت هر نسخه با نسخهٔ پیش از خودش.
*
* فهرست کامل اثرها روی هر نسخه، سؤال واقعی را جواب نمیدهد: «چه چیزی عوض شد؟». وقتی
* نوبتی به نسخهٔ ۳ ارجاع میدهد و کسی میپرسد چرا قیمتش فرق دارد، همین ستون جواب است.
*/
export default function PolicyVersionDiff({ versions }: { versions: VersionRow[] }) {
if (!versions || versions.length === 0) return null;
const ordered = [...versions].sort((a, b) => a.version - b.version);
return (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{ordered.map((v, i) => {
const previous = i === 0 ? null : ordered[i - 1].snapshot;
const changes = previous
? WATCHED.filter((f) => show(v.snapshot[f.key]) !== show(previous[f.key]))
: [];
return (
<div
key={v.version}
style={{ borderTop: i === 0 ? undefined : '1px solid var(--border)', paddingTop: i === 0 ? 0 : 10 }}
>
<div style={{ display: 'flex', gap: 10, fontSize: 12, alignItems: 'center' }}>
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
{previous === null && (
<span style={{ color: 'var(--text-3)' }}>نسخهٔ نخست</span>
)}
{previous !== null && changes.length === 0 && (
<span style={{ color: 'var(--text-3)' }}>بدون تغییرِ معنادار</span>
)}
</div>
{changes.map((f) => (
<div
key={f.key}
style={{ display: 'flex', gap: 8, fontSize: 12, marginTop: 6, flexWrap: 'wrap' }}
>
<span style={{ minWidth: 90, color: 'var(--text-2)' }}>{f.label}</span>
<span
style={{
color: 'var(--danger)',
textDecoration: 'line-through',
wordBreak: 'break-all',
}}
>
{show(previous?.[f.key])}
</span>
<span style={{ color: 'var(--text-3)' }}></span>
<span style={{ color: 'var(--success)', wordBreak: 'break-all' }}>
{show(v.snapshot[f.key])}
</span>
</div>
))}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import ResourceUtilizationChart from './ResourceUtilizationChart';
const row = (over: Record<string, unknown> = {}) => ({
resource_uuid: 'r-1',
resource_name: 'اتاق ۱',
role: 'room',
available_minutes: 600,
occupied_minutes: 300,
active_minutes: 200,
utilization: 0.5,
active_ratio: 0.66,
wasted_capacity: false,
...over,
}) as never;
describe('ResourceUtilizationChart', () => {
it('renders a bar per resource that has a calendar', () => {
const { container } = render(
<ResourceUtilizationChart rows={[row(), row({ resource_uuid: 'r-2', resource_name: 'اتاق ۲' })]} />,
);
expect(screen.getByText('بهره‌وری منابع')).toBeInTheDocument();
expect(container.querySelector('.recharts-responsive-container')).not.toBeNull();
});
/** ⭐ `null` صفر نیست — ستون صفر برای منبعِ بی‌تقویم دروغ می‌گوید. */
it('leaves out resources with no calendar instead of drawing them at zero', () => {
const { container } = render(
<ResourceUtilizationChart rows={[row({ utilization: null }), row({ utilization: null, resource_uuid: 'r-2' })]} />,
);
expect(container.textContent).toBe('');
});
});
@@ -0,0 +1,68 @@
import React from 'react';
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { UtilizationRow } from '../types';
/**
* بهرهوری هر منبع در یک نگاه.
*
* جدول شش ستون عدد دارد و برای مقایسه ساخته نشده؛ چشم نمیتواند بگوید کدام منبع
* عقب است. نمودار دقیقاً همان یک سؤال را جواب میدهد و بقیهاش زیرش در جدول میماند.
*
* منبعِ بدون تقویم اینجا **نمیآید**: `null` صفر نیست و ستون صفر دروغ میگوید.
*/
export default function ResourceUtilizationChart({ rows }: { rows: UtilizationRow[] }) {
const data = rows
.filter((r) => r.utilization !== null)
.map((r) => ({
name: r.resource_name,
percent: Math.round((r.utilization ?? 0) * 100),
wasted: r.wasted_capacity,
}));
if (data.length === 0) return null;
return (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>بهرهوری منابع</h3>
<div style={{ width: '100%', height: Math.max(180, data.length * 42) }}>
<ResponsiveContainer>
<BarChart data={data} layout="vertical" margin={{ right: 16, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis type="number" domain={[0, 100]} unit="٪" stroke="var(--text-3)" fontSize={12} />
<YAxis
type="category"
dataKey="name"
width={120}
stroke="var(--text-3)"
fontSize={12}
orientation="right"
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0)}٪`, 'بهره‌وری'] as [string, string]}
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 12,
}}
/>
<Bar dataKey="percent" radius={[0, 6, 6, 0]}>
{/* رنگ از توکن‌ها می‌آید نه از hex — دارک‌مود همین‌جا شکسته می‌شد. */}
{data.map((row) => (
<Cell
key={row.name}
fill={row.wasted ? 'var(--danger)' : 'var(--primary)'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '8px 0 0', lineHeight: 1.8 }}>
منبعی که تقویم ندارد در نمودار نمیآید بهرهوریاش صفر نیست، تعریفنشده است.
</p>
</div>
);
}
@@ -0,0 +1,78 @@
import React from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import {
ClassicEditor,
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
List,
BlockQuote,
Table,
TableToolbar,
Undo,
} from 'ckeditor5';
import translations from 'ckeditor5/translations/fa.js';
import 'ckeditor5/ckeditor5.css';
/**
* ادیتور متن غنی مقالهها.
*
* تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را
* import میکردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت
* ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build
* آماده، فهرست پلاگینها صریح است.
*
* پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه و تا نوار
* ابزارِ دو صفحه از هم واگرا نشود.
*
* فهرست پلاگینها دقیقاً همان دکمههای نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین
* اضافه یعنی markup تازهای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام
* ذخیره حذف میشود.
*/
export default function RichTextEditor({
value,
onChange,
}: {
value: string;
onChange: (html: string) => void;
}) {
return (
<div dir="rtl" className="ck-rtl">
<CKEditor
editor={ClassicEditor}
data={value}
onChange={(_evt, editor) => onChange(editor.getData())}
config={{
licenseKey: 'GPL',
language: 'fa',
translations: [translations],
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
List,
BlockQuote,
Table,
TableToolbar,
Undo,
],
toolbar: [
'heading', '|',
'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|',
'blockQuote', 'insertTable', '|',
'undo', 'redo',
],
table: {
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
},
}}
/>
</div>
);
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import Modal from './ui/Modal';
import PermissionAccordions from './ui/PermissionAccordions';
import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog';
import type { Secretary, SecretaryPermissions } from '../types';
/**
* دسترسیهای یک منشی جدا از فرمِ پروفایل.
*
* تا پیش از این هر دو در یک مودال بودند و افزودنِ یک منشی یعنی تصمیمگیری دربارهٔ
* همهٔ منابع در همان لحظه. با رجیستریِ داینامیک تعداد منابع با هر صفحهٔ تازه بیشتر
* میشود، پس آن مودال ذاتاً بلندتر میشد.
*
* ذخیره روی **همهٔ** ردیفهای رابطه اجرا می‌شود: یک منشی به ازای هر پزشک یک ردیف
* دارد و مجوزها باید در همه یکسان بمانند همان قاعدهای که ویرایش پروفایل دارد.
*/
export default function SecretaryPermissionsModal({
open,
secretary,
links,
isClinic,
readOnly,
onClose,
onSaved,
}: {
open: boolean;
secretary: Secretary | null;
/** uuid هر رابطهٔ پزشک-منشی. */
links: string[];
isClinic: boolean;
readOnly?: boolean;
onClose: () => void;
onSaved?: () => void;
}) {
const catalog = usePermissionCatalog();
const [permissions, setPermissions] = useState<SecretaryPermissions>({});
// شکل را کاتالوگ می‌دهد؛ تا نیامده مقداردهی نمی‌شود وگرنه سوییچ‌ها از
// uncontrolled به controlled می‌پرند و مقدارِ ذخیره‌شده پاک می‌شود.
useEffect(() => {
if (!open || catalog.isLoading) return;
setPermissions(alignPermissions(secretary?.permissions, catalog.resources));
}, [open, secretary, catalog.isLoading, catalog.resources]);
const save = useMutation({
mutationFn: () =>
Promise.all(
links.map((uuid) =>
api.patch(`/api/v1/secretary/${uuid}`, {
permissions: { version: 1, resources: permissions },
}),
),
),
onSuccess: () => {
toast.success('دسترسی‌های منشی ذخیره شد');
onSaved?.();
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const setPermission = (section: string, item: string, value: boolean) =>
setPermissions((prev) => ({
...prev,
[section]: { ...prev[section], [item]: value },
}));
return (
<Modal
open={open}
size="lg"
title={`دسترسی‌های ${secretary?.user_name ?? 'منشی'}`}
onClose={onClose}
footer={
<>
<button type="button" className="btn ghost sm" onClick={onClose}>
{readOnly ? 'بستن' : 'انصراف'}
</button>
{!readOnly && (
<button
type="button"
className="btn primary sm"
disabled={save.isPending || catalog.isLoading}
onClick={() => save.mutate()}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسی‌ها'}
</button>
)}
</>
}
>
{catalog.isLoading ? (
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
) : catalog.isError ? (
<p className="muted">فهرست دسترسیها خوانده نشد. مودال را دوباره باز کنید.</p>
) : (
<>
<p className="muted" style={{ fontSize: 12.5, marginBottom: 14, lineHeight: 1.9 }}>
هر بخش را باز کنید تا اجزایش را ببینید. عددِ کنار هر بخش میگوید چند مورد از
آن روشن است.
</p>
<PermissionAccordions
permissions={permissions}
onChange={setPermission}
disabled={readOnly}
isClinic={isClinic}
resources={catalog.resources}
/>
</>
)}
</Modal>
);
}
@@ -0,0 +1,107 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import SearchableSelect from './ui/SearchableSelect';
import { api, ApiError, type ApiResponse } from '../lib/api';
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
import type { CatalogCategory } from '../types';
type Flat = { uuid: string; name: string; depth: number };
function flatten(nodes: CatalogCategory[], depth = 0): Flat[] {
return nodes.flatMap((n) => [
{ uuid: n.uuid, name: n.name, depth },
...flatten(n.children ?? [], depth + 1),
]);
}
/**
* دستهٔ یک سرویس فقط **انتخاب** از کاتالوگ سراسری.
*
* ساخت، ویرایش و حذف دسته عمداً اینجا نیست؛ فقط در «تنظیمات دستهبندیها». اگر هر
* صفحهای بتواند دسته بسازد، «تمام بدن» چند بار با املاهای مختلف ساخته میشود و یال
* «شامل بودن» دیگر چیزی را نمیگیرد.
*/
export default function ServiceCategoryTab({ serviceUuid, categoryUuid, canEdit }: {
serviceUuid: string;
categoryUuid: string | null;
canEdit: boolean;
}) {
const qc = useQueryClient();
const { tree, loading } = useCatalogCategories();
const [selected, setSelected] = useState<string | null>(categoryUuid);
useEffect(() => setSelected(categoryUuid), [categoryUuid]);
const all = useMemo(() => flatten(tree), [tree]);
const { includes } = useCategoryIncludes(selected ?? undefined);
const save = useMutation({
mutationFn: (uuid: string | null) =>
api.patch<ApiResponse<unknown>>(`/api/v1/service-item/${serviceUuid}`, { catalog_category_uuid: uuid }),
onSuccess: () => {
toast.success('دسته‌بندی سرویس ذخیره شد');
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ دسته‌بندی ناموفق بود'),
});
return (
<div className="card card-pad" style={{ display: 'grid', gap: 14 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
دستهبندی سراسری کلینیک است و اینجا فقط انتخاب میشود. ساخت، ویرایش و حذف فقط در{' '}
<Link to="/admin/service-categories" style={{ color: 'var(--primary)' }}>تنظیمات دستهبندیها</Link>{' '}
انجام میشود.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : all.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ دستهبندیای تعریف نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 6, maxWidth: 380 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>دستهبندی این سرویس</label>
<SearchableSelect
options={all.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
value={selected}
onChange={(v) => setSelected(v ? String(v) : null)}
placeholder="بدون دسته‌بندی"
isDisabled={!canEdit}
isClearable
height={38}
/>
</div>
)}
{selected && includes.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>این دسته شامل:</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{includes.map((c) => (
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
))}
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
انتخاب همزمان این سرویس با سرویسی از این زیرمجموعهها هنگام رزرو رد میشود.
</span>
</div>
)}
{canEdit && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn primary"
disabled={save.isPending || selected === categoryUuid}
onClick={() => save.mutate(selected)}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
@@ -7,6 +7,7 @@ import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import type { ServiceItem } from '../types';
import Switch from './ui/Switch';
interface TenantInsurance {
uuid: string;
@@ -111,15 +112,12 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
پوشش پیشفرض قرارداد: {contract.coverage_percent}٪
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={draft.covered}
disabled={isLoading}
onChange={(e) => setDraft((d) => ({ ...d, covered: e.target.checked }))}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<Switch
checked={draft.covered}
disabled={isLoading}
onChange={(v) => setDraft((d) => ({ ...d, covered: v }))}
ariaLabel="پوشش بیمه برای این سرویس"
/>
</div>
{/* بدنه — فقط وقتی پوشش فعال است */}
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, within } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
import ServiceItemFormModal from './ServiceItemFormModal';
import { api } from '../lib/api';
const BOOKABLE_LABEL = 'نمایش در نوبت‌دهی آنلاین';
beforeEach(() => {
vi.spyOn(api, 'get').mockImplementation(async (path: string) => {
if (path.startsWith('/api/v1/staff')) return { data: [] } as never;
if (path.startsWith('/api/v1/inventory-packages')) return { data: [] } as never;
if (path.startsWith('/api/v1/inventory-items')) return { data: { items: [] } } as never;
return { data: [] } as never;
});
});
const renderModal = () =>
renderWithProviders(
<ServiceItemFormModal item="create" sectionUuid="section-1" onClose={() => {}} />,
);
describe('ServiceItemFormModal', () => {
it('سوییچ رزرو آنلاین برچسب صریح دارد', () => {
renderModal();
expect(screen.getByRole('switch', { name: BOOKABLE_LABEL })).toBeInTheDocument();
// برچسب قدیمی مبهم بود: معلوم نبود «نوبت‌دهی» یعنی تقویم داخلی یا رزرو بیمار.
expect(screen.queryByRole('switch', { name: 'نمایش در نوبت‌دهی' })).not.toBeInTheDocument();
});
it('متن راهنما می‌گوید فعال بودنش یعنی چه', () => {
renderModal();
expect(
screen.getByText(/بیمار می‌تواند این سرویس را به‌صورت آنلاین رزرو کند/),
).toBeInTheDocument();
});
it('راهنما نامِ دسترسی‌پذیرِ سوییچ را آلوده نمی‌کند', () => {
renderModal();
// نام باید همان برچسب کوتاه بماند، نه برچسب + یک جملهٔ کامل.
expect(screen.getByRole('switch').getAttribute('aria-label')).toBe(BOOKABLE_LABEL);
});
it('قیمت و زمان متوسط یک ردیف‌اند و پرسنل تمام‌عرض', () => {
renderModal();
// مودال در portal رندر می‌شود، پس جستجو از document است نه container.
const grids = Array.from(document.querySelectorAll('div'))
.filter((el) => (el as HTMLElement).style.gridTemplateColumns === '1fr 1fr');
// تنها ردیفِ دوستونی همان جفتِ عددی است؛ پرسنل با چیپ‌هایش ردیف را می‌شکست.
expect(grids).toHaveLength(1);
expect(within(grids[0] as HTMLElement).getByText('قیمت پایه (تومان) *')).toBeInTheDocument();
expect(within(grids[0] as HTMLElement).getByText('زمان متوسط (دقیقه)')).toBeInTheDocument();
});
it('دکمهٔ انصراف واریانت دارد و نامرئی نیست', () => {
renderModal();
expect(screen.getByRole('button', { name: 'انصراف' })).toHaveClass('secondary');
});
});
@@ -15,6 +15,7 @@ import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import Switch from './ui/Switch';
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
@@ -159,7 +160,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
size="md"
footer={
<>
<button type="button" className="btn" onClick={onClose}>انصراف</button>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button type="submit" form="service-item-form" className="btn primary" disabled={saving}>
{saving ? 'در حال ذخیره...' : 'ذخیره سرویس'}
</button>
@@ -182,6 +183,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
)}
</div>
{/* دو عددِ کوتاه کنار هم؛ پیش از این «قیمت» با «پرسنل مسئول» همردیف بود و
با افزودن هر پرسنل، چیپها ستون را بلند میکردند و ردیف بههم میریخت. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="field-label">قیمت پایه (تومان) *</label>
@@ -199,16 +202,40 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
)}
</div>
<div>
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={''}
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
/>
{selectedStaffUuids.length > 0 && (
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ value: c.key, label: c.label }))}
value={form.watch('service_category') || null}
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
placeholder="انتخاب نوع خدمت"
noOptionsMessage="نوعی تعریف نشده است"
height={42}
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
{/* تمام‌عرض: چیپ‌های پرسنل چند سطر می‌شوند و در نیمْ‌ستون می‌شکستند. */}
<div>
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={''}
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
/>
{selectedStaffUuids.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selectedStaffUuids.map((uuid) => (
<span key={uuid} style={{
@@ -228,42 +255,20 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
))}
</div>
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
<span className="switch">
<input
type="checkbox"
checked={form.watch('bookable') ?? false}
onChange={(e) => form.setValue('bookable', e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
</label>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ value: c.key, label: c.label }))}
value={form.watch('service_category') || null}
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
placeholder="انتخاب نوع خدمت"
noOptionsMessage="نوعی تعریف نشده است"
height={42}
{/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این
در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی همتراز شده بود. */}
<div style={{
padding: '12px 13px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
}}>
<Switch
checked={form.watch('bookable') ?? false}
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
label="نمایش در نوبت‌دهی آنلاین"
hint="با فعال بودن این گزینه، بیمار می‌تواند این سرویس را به‌صورت آنلاین رزرو کند."
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
<div>
@@ -332,7 +337,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
{editing && onManageInsurance && (
<button
type="button"
className="btn sm"
className="btn secondary sm"
style={{ flexShrink: 0 }}
onClick={() => { const it = editing; onClose(); onManageInsurance(it); }}
>
@@ -1,166 +0,0 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import type { ServiceItem } from '../types';
interface TariffRow {
uuid: string;
year: number;
price_rials: number;
is_active: boolean;
}
interface TariffResponse {
current_year: number;
default_price_rials: number;
data: TariffRow[];
}
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
const qc = useQueryClient();
const [year, setYear] = useState<number | ''>('');
const [price, setPrice] = useState(0);
const [editYear, setEditYear] = useState<number | null>(null);
const [editPrice, setEditPrice] = useState(0);
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
queryKey: ['service-tariffs', item?.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
enabled: !!item,
});
const resp = (data as any)?.data as TariffResponse | undefined;
const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]);
const currentYear = resp?.current_year;
// سال‌های قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سال‌های ثبت‌شده.
const yearOptions = useMemo(() => {
if (!currentYear) return [];
const used = new Set(tariffs.map((t) => t.year));
const opts: { value: number; label: string }[] = [];
for (let y = currentYear + 2; y >= currentYear - 5; y--) {
if (used.has(y)) continue;
opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) });
}
return opts;
}, [currentYear, tariffs]);
const addMut = useMutation({
mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: tomanToRial(price) }),
onSuccess: () => {
toast.success('تعرفه ذخیره شد');
setYear('');
setPrice(0);
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
qc.invalidateQueries({ queryKey: ['service-items'] });
},
onError: (e: Error) => toast.error(e.message),
});
const editMut = useMutation({
mutationFn: (vars: { year: number; price: number }) =>
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${vars.year}`, { price_rials: tomanToRial(vars.price) }),
onSuccess: () => {
toast.success('تعرفه ویرایش شد');
setEditYear(null);
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
qc.invalidateQueries({ queryKey: ['service-items'] });
},
onError: (e: Error) => toast.error(e.message),
});
const startEdit = (t: TariffRow) => { setEditYear(t.year); setEditPrice(rialToToman(t.price_rials)); };
return (
<Modal open={!!item} onClose={onClose} title={`تعرفه‌های سالانه — ${item?.name ?? ''}`} size="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{resp && (
<div style={{
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
</div>
)}
{/* افزودن تعرفه‌ی جدید */}
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: 10, alignItems: 'end' }}>
<div style={{ minWidth: 0 }}>
<label className="field-label">سال (شمسی)</label>
<SearchableSelect
options={yearOptions}
value={year === '' ? '' : year}
onChange={(v) => setYear(v != null && v !== '' ? Number(v) : '')}
placeholder="انتخاب سال"
noOptionsMessage="سالی باقی نمانده"
height={40}
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">تعرفه (تومان)</label>
<PriceInput className="input" style={{ height: 40 }} value={price} onChange={setPrice} placeholder="مبلغ" min={0} />
</div>
<button className="btn primary" style={{ height: 40 }} disabled={year === '' || addMut.isPending} onClick={() => addMut.mutate()}>
{addMut.isPending ? '...' : 'افزودن'}
</button>
</div>
</div>
{/* فهرست تعرفه‌ها */}
{isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{tariffs.map((t) => {
const isCurrent = t.year === currentYear;
const isEditing = editYear === t.year;
return (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
borderRadius: 9, border: '1px solid var(--border)',
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
}}>
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
سال {formatYear(t.year)}
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
</span>
{isEditing ? (
<>
<div style={{ flex: 1 }}>
<PriceInput className="input" style={{ height: 36 }} value={editPrice} onChange={setEditPrice} min={0} />
</div>
<button className="btn primary sm" disabled={editMut.isPending} onClick={() => editMut.mutate({ year: t.year, price: editPrice })} title="ذخیره">
<CheckIcon style={{ width: 14 }} />
</button>
<button className="btn sm" onClick={() => setEditYear(null)} title="انصراف">
<XMarkIcon style={{ width: 14 }} />
</button>
</>
) : (
<>
<span style={{ flex: 1, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }} dir="ltr">{formatRial(t.price_rials)}</span>
<button className="btn sm ghost" onClick={() => startEdit(t)} title="ویرایش">
<PencilIcon style={{ width: 14 }} />
</button>
</>
)}
</div>
);
})}
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,288 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import Input from './ui/Input';
import SearchableSelect from './ui/SearchableSelect';
import { formatNumber } from '../lib/utils';
import type { TreatmentCaseDetail, TreatmentCaseStatus } from '../types';
const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
{ value: 'active', label: 'در جریان' },
{ value: 'completed', label: 'تمام شده' },
{ value: 'abandoned', label: 'رها شده' },
];
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
interface StaffRow { uuid: string; full_name: string }
/**
* ویرایش پروندهٔ درمان.
*
* پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره
* عوض میشوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند میگیرد؛ فرم آن خطا را
* نشان میدهد، تکرارش نمیکند.
*/
export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
caseUuid: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-case', caseUuid],
queryFn: () => api.get<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${caseUuid}`),
});
// همان اندپوینتی که صفحهٔ نوبت‌ها می‌خواند: فقط پزشکانِ مجازِ همین محیط.
// پاسخش دو لایه تو در تو است (`data.data`) — الگوی شناخته‌شدهٔ همین اندپوینت.
const doctorsQ = useQuery<ApiResponse<{ data: DoctorRow[] }>>({
queryKey: ['clinic-doctors-lite'],
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const staffQ = useQuery<ApiResponse<StaffRow[]>>({
queryKey: ['staff-list'],
queryFn: () => api.get('/api/v1/staff'),
staleTime: 60_000,
});
const detail = data?.data;
return (
<Modal open title="ویرایش پروندهٔ درمان" size="sm" onClose={onClose} footer={null}>
{isLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : isError || !detail ? (
<div style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پرونده ناموفق بود.</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
) : (
<EditForm
detail={detail}
doctors={doctorsQ.data?.data?.data ?? []}
doctorsLoading={doctorsQ.isLoading}
staff={staffQ.data?.data ?? []}
staffLoading={staffQ.isLoading}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
onClose();
}}
onClose={onClose}
/>
)}
</Modal>
);
}
function EditForm({ detail, doctors, doctorsLoading, staff, staffLoading, onSaved, onClose }: {
detail: TreatmentCaseDetail;
doctors: DoctorRow[];
doctorsLoading: boolean;
staff: StaffRow[];
staffLoading: boolean;
onSaved: () => void;
onClose: () => void;
}) {
const [status, setStatus] = useState<TreatmentCaseStatus>(detail.status);
const [supervisor, setSupervisor] = useState<string | null>(detail.supervisor?.uuid ?? null);
const [total, setTotal] = useState(String(detail.total_sessions));
const [areas, setAreas] = useState<string[]>(
detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null),
);
const [staffUuids, setStaffUuids] = useState<string[]>(detail.assigned_staff.map((s) => s.uuid));
// ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
// شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است.
const orphanAreas = useMemo(
() => detail.areas.filter((a) => a.category_uuid === null).map((a) => a.name),
[detail.areas],
);
const minTotal = detail.completed_sessions;
const save = useMutation({
mutationFn: () => api.patch<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${detail.uuid}`, {
status,
supervisor_doctor_uuid: supervisor,
area_uuids: areas,
staff_uuids: staffUuids,
total_sessions: Number(total) || 0,
}),
onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); },
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'ویرایش پرونده ناموفق بود'),
});
const toggleArea = (uuid: string) =>
setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const toggleStaff = (uuid: string) =>
setStaffUuids((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const totalValid = Number(total) >= 2 && Number(total) <= 60;
const valid = areas.length > 0 && totalValid;
return (
<div style={{ display: 'grid', gap: 16 }}>
<div style={{
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
}}>
<span>
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
<b>{detail.patient.name || 'بدون نام'}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>سرویس: </span>
<b>{detail.service.name}</b>
</span>
</div>
<div className="field-block">
<label>وضعیت پرونده</label>
<div className="seg" style={{ display: 'flex' }}>
{STATUS_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
className={status === o.value ? 'on' : ''}
aria-pressed={status === o.value}
onClick={() => setStatus(o.value)}
style={{ flex: 1, justifyContent: 'center' }}
>
{o.label}
</button>
))}
</div>
</div>
<div className="field-block">
<label htmlFor="case-supervisor">پزشک ناظر</label>
<SearchableSelect
inputId="case-supervisor"
options={doctors.map((d) => ({ value: d.uuid, label: d.name ?? d.full_name ?? '' }))}
value={supervisor}
onChange={(v) => setSupervisor(v === null ? null : String(v))}
placeholder="بدون پزشک ناظر"
isLoading={doctorsLoading}
isClearable
height={40}
/>
</div>
<div className="field-block">
<label>نواحی درمان</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{detail.available_areas.map((a) => {
const on = areas.includes(a.uuid);
return (
<button
key={a.uuid}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggleArea(a.uuid)}
style={{
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: on ? 600 : 400,
}}
>
{a.name}
</button>
);
})}
</div>
{areas.length === 0 && <span className="field-err">حداقل یک ناحیه لازم است</span>}
{orphanAreas.length > 0 && (
<span className="field-hint">
نواحیِ «{orphanAreas.join('، ')}» در سابقه هستند ولی دستهبندیشان حذف شده و قابل انتخاب نیستند.
</span>
)}
</div>
<div className="field-block">
<label>پرسنل</label>
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
{staffLoading ? (
<span className="field-hint">در حال خواندن فهرست پرسنل</span>
) : staff.length === 0 ? (
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{staff.map((p) => {
const on = staffUuids.includes(p.uuid);
return (
<button
key={p.uuid}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggleStaff(p.uuid)}
style={{
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: on ? 600 : 400,
}}
>
{p.full_name}
</button>
);
})}
</div>
)}
<span className="field-hint">
{staffUuids.length === 0
? 'خالی یعنی هر پرسنلِ مجازِ این سرویس می‌تواند جلسات را انجام دهد.'
: 'جلسات این پرونده فقط در صف همین افراد دیده می‌شود.'}
</span>
{detail.performed_by.length > 0 && (
<span className="field-hint">
تا اینجا انجامدهنده: {detail.performed_by.map((p) => p.name).join('، ')}
</span>
)}
</div>
<div className="field-block">
<label htmlFor="case-total">تعداد جلسات</label>
<div className="field" style={{ maxWidth: 140 }}>
<Input
id="case-total"
numeric
className=""
value={total}
onChange={(e) => setTotal(e.target.value.replace(/\D/g, '').slice(0, 2))}
/>
</div>
<span className={totalValid ? 'field-hint' : 'field-err'}>
{totalValid
? `${formatNumber(minTotal)} جلسه انجام شده. جلسه‌ای که نوبت دارد یا انجام شده حذف نمی‌شود.`
: 'تعداد جلسات باید بین ۲ و ۶۰ باشد'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-start' }}>
<button
type="button"
className="btn primary"
onClick={() => save.mutate()}
disabled={!valid || save.isPending}
>
{save.isPending ? 'در حال ذخیره…' : 'ذخیره'}
</button>
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
</div>
</div>
);
}
@@ -0,0 +1,405 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { PlusIcon, TrashIcon, XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Switch from './ui/Switch';
import SearchableSelect from './ui/SearchableSelect';
import Input from './ui/Input';
import ConfirmDialog from './ui/ConfirmDialog';
import { api, ApiError, type ApiResponse } from '../lib/api';
interface ProtocolStep {
step_number: number;
offset_days: number;
}
interface ProtocolStaff {
uuid: string;
name: string;
}
interface TreatmentProtocol {
uuid: string;
/** آیا هیچ منبعی این سرویس را ارائه می‌دهد؛ نبودش رزرو را قفل نمی‌کند ولی باید دیده شود. */
service_has_resources?: boolean;
active: boolean;
total_sessions: number;
supervisor: { uuid: string; name: string } | null;
steps: ProtocolStep[];
staff: ProtocolStaff[];
}
interface StaffRow {
uuid: string;
full_name: string;
active?: boolean;
}
interface DoctorRow {
uuid: string;
name: string;
}
/** پیش‌فرضِ روشن‌کردن سوییچ: کوتاه‌ترین دوره‌ای که معنی دارد. */
const DEFAULT_STEPS: ProtocolStep[] = [
{ step_number: 1, offset_days: 0 },
{ step_number: 2, offset_days: 30 },
];
const MIN_STEPS = 2;
/** خط جداکنندهٔ بخش‌ها — سه بخشِ مستقل نباید به‌هم چسبیده به نظر برسند. */
function Divider() {
return <hr style={{ border: 0, borderTop: '1px solid var(--border)', margin: 0 }} />;
}
/**
* «طول درمان» یک سرویس.
*
* وجودِ پروتکل خودش سوییچ است سرویس بدون پروتکل تکجلسهای است پس روشنکردن یعنی
* ساختن و خاموشکردن یعنی حذف. چون خاموشکردن برگشتناپذیر است و گامها و پرسنل را با
* خودش میبرد، با تأیید صریح انجام میشود نه با یک کلیک.
*
* فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، چون فاصلهٔ درمان به آخرین جلسه
* گره خورده نه به روز باز شدن پرونده.
*/
export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
serviceUuid: string;
canEdit: boolean;
}) {
const qc = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-protocol', serviceUuid],
queryFn: () => api.get<ApiResponse<TreatmentProtocol | null>>(
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
),
});
const { data: staffData } = useQuery({
queryKey: ['staff-list-for-protocol'],
queryFn: () => api.get<ApiResponse<StaffRow[]>>('/api/v1/staff'),
staleTime: 60_000,
});
const { data: doctorData } = useQuery({
queryKey: ['my-clinic-doctors'],
queryFn: () => api.get<ApiResponse<{ data: DoctorRow[] }>>('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const protocol = data?.data ?? null;
const [enabled, setEnabled] = useState(false);
const [steps, setSteps] = useState<ProtocolStep[]>(DEFAULT_STEPS);
const [staffUuids, setStaffUuids] = useState<string[]>([]);
const [supervisor, setSupervisor] = useState<string | null>(null);
const [confirmOff, setConfirmOff] = useState(false);
useEffect(() => {
setEnabled(protocol !== null);
setSteps(protocol?.steps?.length ? protocol.steps : DEFAULT_STEPS);
setStaffUuids(protocol?.staff?.map((s) => s.uuid) ?? []);
setSupervisor(protocol?.supervisor?.uuid ?? null);
}, [protocol]);
const staffOptions = (staffData?.data ?? [])
.filter((s) => s.active !== false)
.map((s) => ({ value: s.uuid, label: s.full_name }));
const doctorOptions = (doctorData?.data?.data ?? []).map((d) => ({ value: d.uuid, label: d.name }));
const save = useMutation({
mutationFn: () => api.put<ApiResponse<TreatmentProtocol>>(
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
{
steps: steps.map((s, i) => ({ step_number: i + 1, offset_days: s.offset_days })),
staff_uuids: staffUuids,
supervisor_doctor_uuid: supervisor,
},
),
onSuccess: () => {
toast.success('طول درمان ذخیره شد');
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ طول درمان ناموفق بود'),
});
const remove = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/service-item/${serviceUuid}/treatment-protocol`),
onSuccess: () => {
toast.success('طول درمان خاموش شد');
setConfirmOff(false);
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'خاموش‌کردن ناموفق بود'),
});
/**
* روشنکردن بیخطر است و فوری اعمال میشود؛ خاموشکردن پروتکل را با همهٔ گامها و
* پرسنلش پاک میکند، پس تأیید میخواهد.
*/
const toggle = (on: boolean) => {
if (on) {
setEnabled(true);
return;
}
if (protocol === null) {
setEnabled(false);
return;
}
setConfirmOff(true);
};
const setOffset = (index: number, value: number) => {
setSteps((prev) => prev.map((s, i) => (i === index ? { ...s, offset_days: value } : s)));
};
const addStep = () => {
setSteps((prev) => [
...prev,
{ step_number: prev.length + 1, offset_days: prev[prev.length - 1]?.offset_days || 30 },
]);
};
const removeStep = (index: number) => {
setSteps((prev) => (prev.length <= MIN_STEPS ? prev : prev.filter((_, i) => i !== index)));
};
const addStaff = (uuid: string | number | null) => {
const id = uuid === null ? '' : String(uuid);
if (id && !staffUuids.includes(id)) setStaffUuids((prev) => [...prev, id]);
};
if (isLoading) {
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
}
/**
* بدون این، شکستِ کوئری `protocol = null` میداد و صفحه سوییچِ خاموش نشان میداد
* یعنی به مدیر میگفت این سرویس تکجلسهای است، در حالی که فقط خواندن شکست خورده.
*/
if (isError) {
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
<strong style={{ fontSize: 13.5 }}>طول درمان این سرویس خوانده نشد</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
تا وقتی خطا برطرف نشده، وضعیت واقعی این سرویس معلوم نیست.
</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>
تلاش دوباره
</button>
</div>
);
}
const dirty = enabled && staffUuids.length > 0;
return (
<>
<div className="card card-pad" style={{ display: 'grid', gap: 18 }}>
<Switch
checked={enabled}
onChange={toggle}
disabled={!canEdit}
label="طول درمان"
hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای."
/>
{enabled && protocol?.service_has_resources === false && (
<div
style={{
display: 'flex', gap: 10, padding: '12px 14px',
background: 'var(--warning-bg)', borderRadius: 'var(--r-sm)',
}}
>
<ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--warning)', flexShrink: 0 }} />
<div style={{ display: 'grid', gap: 4 }}>
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
</span>
</div>
</div>
)}
{enabled && (
<>
<Divider />
<section style={{ display: 'grid', gap: 12 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>
جلسات دوره{' '}
<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 13 }}>
({steps.length} جلسه)
</span>
</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
فاصلهٔ هر جلسه از <b>جلسهٔ قبل</b> حساب میشود، نه از شروع دوره. اگر بیمار دیر بیاید،
بقیهٔ دوره هم جابهجا میشود.
</p>
</div>
{steps.map((step, index) => (
<div
key={index}
style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
paddingBottom: 10,
borderBottom: index < steps.length - 1 ? '1px dashed var(--border)' : 'none',
}}
>
<span style={{ minWidth: 68, fontSize: 13, fontWeight: 600 }}>جلسهٔ {index + 1}</span>
{index === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 180 }}>
شروع دوره همان روز اولین نوبت
</span>
) : (
<>
<Input
numeric
value={String(step.offset_days)}
disabled={!canEdit}
onChange={(e) => setOffset(index, Number(e.target.value || 0))}
aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`}
style={{ width: 96 }}
/>
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 140 }}>
روز بعد از جلسهٔ قبل
</span>
</>
)}
{canEdit && index > 0 && (
<button
type="button"
className="mini-btn danger"
onClick={() => removeStep(index)}
disabled={steps.length <= MIN_STEPS}
title={steps.length <= MIN_STEPS ? 'دوره حداقل دو جلسه دارد' : 'حذف این جلسه'}
aria-label={`حذف جلسهٔ ${index + 1}`}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</div>
))}
{canEdit && (
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
</button>
)}
</section>
<Divider />
<section style={{ display: 'grid', gap: 10 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پرسنل مجاز</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
منشی هنگام رزرو فقط از میان همینها انتخاب میکند.
</p>
</div>
{canEdit && (
<SearchableSelect
options={staffOptions.filter((o) => !staffUuids.includes(String(o.value)))}
value={null}
onChange={addStaff}
placeholder="افزودن پرسنل..."
ariaLabel="افزودن پرسنل مجاز"
/>
)}
{staffUuids.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>
حداقل یک پرسنل الزامی است بدون آن ذخیره نمیشود.
</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{staffUuids.map((uuid) => (
<span
key={uuid}
className="badge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 2, paddingInlineEnd: 2 }}
>
{staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid}
{canEdit && (
<button
type="button"
className="mini-btn danger"
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
aria-label="حذف این پرسنل"
>
<XMarkIcon style={{ width: 14, height: 14 }} />
</button>
)}
</span>
))}
</div>
)}
</section>
<Divider />
<section style={{ display: 'grid', gap: 10 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پزشک ناظر</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.
</p>
</div>
<SearchableSelect
options={doctorOptions}
value={supervisor}
onChange={(v) => setSupervisor(v === null ? null : String(v))}
placeholder="بدون پزشک ناظر"
isClearable
isDisabled={!canEdit}
ariaLabel="پزشک ناظر دوره"
/>
</section>
</>
)}
</div>
{/* نوار ذخیرهٔ چسبان: فرم بلند است و دکمهٔ ته صفحه یعنی اسکرول اجباری، به‌ویژه در موبایل. */}
{enabled && canEdit && (
<div className="save-bar">
<div className="sb-msg">
{staffUuids.length === 0
? 'برای ذخیره حداقل یک پرسنل مجاز انتخاب کنید'
: `${steps.length} جلسه · ${staffUuids.length} پرسنل مجاز`}
</div>
<div className="sb-actions">
<button
type="button"
className="btn primary"
onClick={() => save.mutate()}
disabled={save.isPending || !dirty}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ طول درمان'}
</button>
</div>
</div>
)}
<ConfirmDialog
open={confirmOff}
title="خاموش کردن طول درمان"
message="با این کار پروتکل این سرویس همراه با گام‌ها و فهرست پرسنل مجازش حذف می‌شود و سرویس دوباره تک‌جلسه‌ای می‌گردد. پرونده‌های درمانِ باز دست نمی‌خورند."
confirmLabel="خاموش کن"
danger
loading={remove.isPending}
onConfirm={() => remove.mutate()}
onCancel={() => setConfirmOff(false)}
/>
</>
);
}
@@ -4,6 +4,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
export type WalletMode = 'charge' | 'withdraw';
@@ -59,12 +60,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
const dismiss = useOverlayDismiss(onClose, open);
if (!open) return null;
@@ -103,8 +99,8 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
};
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-head">
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
<button type="button" className="mini-btn" onClick={onClose}>
@@ -29,51 +29,92 @@ function render() {
beforeEach(() => {
vi.clearAllMocks();
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
get.mockResolvedValue({ success: true, data: [] });
// مودال همیشه جزئیات نوبت را از سرور می‌گیرد — ردیفِ فهرست مبلغ ویزیت ندارد.
get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? Promise.resolve({ success: true, data: appointment })
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
: Promise.resolve({ success: true, data: [] }));
post.mockResolvedValue({ success: true, data: {} });
});
/** همهٔ فیلدهای مبلغ (تومان) در ردیف‌های پرداخت. */
/**
* فرم ویزارد شد: «بیمه و هزینه» «پرداخت» «تأیید».
* ردیفهای پرداخت در مرحلهٔ دوماند و وضعیت پرداخت در مرحلهٔ سوم.
*/
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
/** همهٔ فیلدهای مبلغ (تومان) در ردیف‌های پرداخت — مرحلهٔ «پرداخت». */
function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
}
/**
* مودال تا رسیدنِ جزئیات نوبت از سرور، دکمههایش قفل است مبلغ نباید از ردیفِ
* ناقصِ فهرست خوانده شود. هر تست اول منتظر همین میماند.
*/
async function settle() {
await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled());
}
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */
function goToPayment() {
nextStep();
return amountInputs();
}
/** تا مرحلهٔ آخر جلو می‌رود؛ وضعیت پرداخت و دکمهٔ قطعی آنجا هستند. */
function goToReview() {
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
}
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', async () => {
render();
await settle();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => {
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', async () => {
render();
await settle();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => {
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', async () => {
render();
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => {
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', async () => {
render();
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
expect(submit).not.toBeDisabled();
await settle();
const rows = goToPayment();
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
fireEvent.change(rows[0], { target: { value: '9000000' } });
expect(screen.getByText('مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
// پرداختِ بیشتر از مبلغ، حتی جلوی رفتن به مرحلهٔ تأیید را می‌گیرد.
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
fireEvent.change(rows[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeDisabled();
});
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render();
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
@@ -84,8 +125,9 @@ describe('ConfirmAppointmentModal', () => {
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render();
await settle();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
@@ -94,6 +136,7 @@ describe('ConfirmAppointmentModal', () => {
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
@@ -106,8 +149,10 @@ describe('ConfirmAppointmentModal', () => {
}));
});
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => {
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2);
@@ -136,12 +181,20 @@ const SUPP_CONTRACT = {
category_coverages: { outpatient: 90, inpatient: 60 },
};
/**
* @param detail نوبتی که اندپوینت جزئیات برمیگرداند مودال همیشه آن را میخواند،
* چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد.
*/
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
detail: unknown = referenceAppointment,
) {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/appointment/')) {
return Promise.resolve({ success: true, data: detail });
}
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({
success: true,
@@ -185,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
@@ -196,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
]);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
@@ -204,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
@@ -213,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -220,8 +277,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰');
expect(goToPayment()[0]).toHaveValue('۱۷۸٬۵۶۰');
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
@@ -234,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
@@ -242,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -252,8 +312,10 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
@@ -267,48 +329,223 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000);
const priceless = { uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] };
mockInsurance(BOTH, 5_952_000, [CONTRACT], priceless);
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
appointment={priceless}
onClose={() => {}}
/>,
);
await settle();
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
});
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
mockInsurance(BOTH, 9_000_000);
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
await screen.findByText('مبلغ قابل پرداخت');
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
});
it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات بستری');
await pick('بدون بیمه', 'بیمه ایران');
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
await screen.findByText('سهم بیمار (قابل پرداخت)');
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
});
});
describe('ConfirmAppointmentModal — ویزارد', () => {
const stepLabels = () =>
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim());
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', async () => {
render();
await settle();
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
expect(screen.getByText('جمع کل')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('0')).toBeNull();
});
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => {
render();
await settle();
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
expect(screen.getByText('جمع کل')).toBeInTheDocument();
// برگشت به پرداخت: مبلغ همان است، نه پیش‌فرضِ کل.
nextStep();
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
});
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
});
});
/** جمع نوبتِ تست: ۲۱۲۰۰۰۰ + ۸۸۰۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان. */
describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => {
const percentButton = (percent: string) =>
screen.getAllByRole('button', { name: `${percent}٪` })[0];
it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', async () => {
render();
await settle();
const [amount] = goToPayment();
fireEvent.click(percentButton('۲۰'));
expect(amount).toHaveValue('۶۰٬۰۰۰');
fireEvent.click(percentButton('۵۰'));
expect(amount).toHaveValue('۱۵۰٬۰۰۰');
fireEvent.click(percentButton('۷۰'));
expect(amount).toHaveValue('۲۱۰٬۰۰۰');
fireEvent.click(percentButton('۱۰۰'));
expect(amount).toHaveValue('۳۰۰٬۰۰۰');
});
it('درصدِ اعمال‌شده تا مرحلهٔ ثبت می‌ماند و به سرور می‌رسد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(percentButton('۵۰'));
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1].payments).toEqual([
{ method: 'cash', amount_rials: 1_500_000 },
]);
});
it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
const rows = amountInputs();
expect(rows).toHaveLength(2);
// ۲۰٪ روی ردیف دوم، بدون اثر روی ردیف اول که پیش‌فرضِ کامل دارد.
const secondRowPercent = screen.getAllByRole('button', { name: '۲۰٪' })[1];
fireEvent.click(secondRowPercent);
expect(rows[0]).toHaveValue('۳۰۰٬۰۰۰');
expect(rows[1]).toHaveValue('۶۰٬۰۰۰');
});
it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', async () => {
const free = { uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] };
// جزئیات هم باید همان نوبتِ بی‌هزینه باشد؛ مودال مبلغ را از سرور می‌خواند.
get.mockImplementation((url: string) =>
Promise.resolve({ success: true, data: url.startsWith('/api/v1/appointment/') ? free : [] }));
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a2"
appointment={free}
onClose={() => {}}
/>,
);
await settle();
nextStep();
expect(screen.queryByRole('button', { name: '۵۰٪' })).not.toBeInTheDocument();
});
});
/**
* باگ واقعی: تایم‌لاین ردیفِ فهرست نوبتها را پاس میداد و آن ردیف اصلاً
* `visit_price_rials` ندارد. مودال به همان اعتماد میکرد، «۰ تومان» نشان میداد و
* همان صفر را ثبت میکرد هزینهٔ ویزیتِ ثبتشده در نوبت نادیده میماند.
*/
describe('ConfirmAppointmentModal — ردیفِ ناقصِ فهرست', () => {
/** دقیقاً شکل ردیفِ `/api/v1/my/appointments`: بدون مبلغ و بدون بیمه. */
const listRow = {
uuid: 'a1',
version: 1,
patient_name: 'محمد رضایی',
service_items: [],
};
it('مبلغ را از جزئیات سرور می‌گیرد، نه از ردیفِ پاس‌داده‌شده', async () => {
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
await settle();
// ۳٬۰۰۰٬۰۰۰ ریالِ جزئیات = ۳۰۰٬۰۰۰ تومان، نه صفرِ ردیفِ فهرست.
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
});
it('تا نیامدنِ جزئیات، رفتن به مرحلهٔ پرداخت قفل است', () => {
let resolveDetail: (v: unknown) => void = () => {};
get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? new Promise((resolve) => { resolveDetail = resolve; })
: Promise.resolve({ success: true, data: [] }));
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
resolveDetail({ success: true, data: appointment });
});
});
@@ -5,12 +5,16 @@ import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import { formatNumber, formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import Stepper from '../ui/Stepper';
/** میان‌بُرهای مبلغ روی هر ردیف پرداخت — درصدی از سهم بیمار. */
const PAYMENT_PERCENTS = [20, 50, 70, 100];
/** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */
const METHOD_OPTIONS = [
@@ -49,6 +53,20 @@ interface Props {
queryKey?: unknown[];
}
/**
* مراحلِ قطعی کردن: اول «چقدر»، بعد «چطور»، آخر «تأیید».
*
* فرم قبلاً یک صفحهٔ بلند بود بیمه، جدول هزینه، ردیفهای پرداخت و خلاصه با هم و
* با دو روش پرداخت از ارتفاع صفحه بلندتر میشد.
*/
const CONFIRM_STEPS = [
{ key: 'cost', title: 'بیمه و هزینه' },
{ key: 'payment', title: 'پرداخت' },
{ key: 'review', title: 'تأیید' },
] as const;
type ConfirmStepKey = (typeof CONFIRM_STEPS)[number]['key'];
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
interface PaymentRow {
id: number;
@@ -99,13 +117,16 @@ export default function ConfirmAppointmentModal({
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر می‌ماند. */
const [touched, setTouched] = useState(false);
const [stepIdx, setStepIdx] = useState(0);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
// جزئیات همیشه گرفته می‌شود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
// فهرست نوبت‌ها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
// ردیف، هزینهٔ ویزیتِ ثبت‌شده را صفر نشان می‌داد و همان صفر را هم ثبت می‌کرد.
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
const detailQuery = useQuery({
queryKey: ['appointment', appointmentUuid],
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
enabled: open && !appointment,
enabled: open,
});
// روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است.
@@ -132,8 +153,10 @@ export default function ConfirmAppointmentModal({
[bankQuery.data],
);
const appt: AppointmentLike | null = appointment
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
// پاسخ سرور مرجع است؛ نوبتِ پاس‌داده‌شده فقط تا رسیدنِ آن، صفحه را خالی نگه نمی‌دارد.
const detail: AppointmentLike | null =
(detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null;
const appt: AppointmentLike | null = detail ?? appointment ?? null;
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
const insurance = useAppointmentInsurance(open);
@@ -225,6 +248,7 @@ export default function ConfirmAppointmentModal({
nextId.current = 1;
setRows([makeRow()]);
setTouched(false);
setStepIdx(0);
}
function patchRow(id: number, patch: Partial<PaymentRow>) {
@@ -237,6 +261,11 @@ export default function ConfirmAppointmentModal({
patchRow(id, { method, methodUuid: '', reference: '' });
}
/** درصدِ سهم بیمار روی همان ردیف می‌نشیند؛ ردیف‌های دیگر دست‌نخورده می‌مانند. */
function applyPercent(id: number, percent: number) {
patchRow(id, { amountToman: rialToToman(Math.round((payable * percent) / 100)) });
}
function addRow() {
setTouched(true);
// ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد.
@@ -253,7 +282,11 @@ export default function ConfirmAppointmentModal({
onClose();
}
const loading = detailQuery.isLoading && !appointment;
// تا رسیدنِ جزئیات، دکمه‌ها قفل‌اند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
const loading = detailQuery.isLoading;
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
const isLastStep = currentStep === 'review';
return (
<Modal
@@ -263,17 +296,34 @@ export default function ConfirmAppointmentModal({
onClose={handleClose}
footer={
<>
{/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمی‌گیرد. */}
<button type="button" className="btn ghost" onClick={handleClose}>
انصراف
</button>
<button
type="button"
className="btn primary"
disabled={loading || overpaid || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
</button>
{stepIdx > 0 && (
<button type="button" className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
مرحلهٔ قبل
</button>
)}
{isLastStep ? (
<button
type="button"
className="btn primary"
disabled={loading || overpaid || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
</button>
) : (
<button
type="button"
className="btn primary"
disabled={loading || overpaid}
onClick={() => setStepIdx(i => i + 1)}
>
مرحلهٔ بعد
</button>
)}
</>
}
>
@@ -295,6 +345,14 @@ export default function ConfirmAppointmentModal({
</div>
)}
<Stepper
steps={CONFIRM_STEPS.map(st => ({ key: st.key, title: st.title }))}
current={currentStep}
ariaLabel="مراحل قطعی کردن نوبت"
/>
{currentStep === 'cost' && (
<>
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{insurance.needsCategoryChoice && (
@@ -390,6 +448,11 @@ export default function ConfirmAppointmentModal({
</div>
</div>
</>
)}
{currentStep === 'payment' && (
<>
{/* پرداخت‌ها — تقسیم بین چند روش */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
@@ -443,6 +506,39 @@ export default function ConfirmAppointmentModal({
)}
</div>
{/* میان‌بُرهای درصدی — پرداخت جزئی رایج است و تایپ دستیِ مبلغ خطا می‌آورد. */}
{payable > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{PAYMENT_PERCENTS.map((percent) => {
const active = tomanToRial(r.amountToman) === Math.round((payable * percent) / 100);
return (
<button
key={percent}
type="button"
className="btn sm"
onClick={() => applyPercent(r.id, percent)}
title={formatRial(Math.round((payable * percent) / 100))}
style={{
height: 30,
padding: '0 12px',
fontSize: 12,
borderRadius: 'var(--r-pill)',
border: `1px solid ${active ? 'var(--primary)' : 'var(--border)'}`,
background: active ? 'var(--primary-soft)' : 'var(--surface)',
color: active ? 'var(--primary)' : 'var(--text-2)',
fontWeight: active ? 700 : 500,
}}
>
{formatNumber(percent)}٪
</button>
);
})}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
از {formatRial(payable)}
</span>
</div>
)}
{/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'pos' && (
<div style={{ display: 'flex', gap: 10 }}>
@@ -508,8 +604,31 @@ export default function ConfirmAppointmentModal({
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
</p>
)}
</>
)}
{currentStep === 'review' && (
<>
{/* خلاصهٔ همان چیزی که ثبت می‌شود */}
<div
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r)',
padding: '4px 14px 10px', marginBottom: 16,
}}
>
<div style={rowStyle}>
<span>مبلغ قابل پرداخت</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(payable)}</strong>
</div>
{rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => (
<div key={r.id} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>{METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method}</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(tomanToRial(r.amountToman))}</strong>
</div>
))}
</div>
{/* خلاصه */}
<div
style={{
background: 'var(--surface-2)', border: '1px solid var(--border)',
@@ -540,6 +659,8 @@ export default function ConfirmAppointmentModal({
</span>
</div>
</div>
</>
)}
</>
)}
</Modal>
@@ -5,6 +5,8 @@
export interface DoctorTab {
uuid: string;
name: string;
/** برچسب کوچکِ کنار نام — مثلاً «بدون ساعت کاری». */
note?: string;
}
export default function DoctorTabs({
@@ -38,6 +40,15 @@ export default function DoctorTabs({
}}
>
{d.name}
{d.note && (
<span style={{
marginRight: 6, fontSize: 10.5, fontWeight: 500, padding: '2px 6px',
borderRadius: 'var(--r-pill)', background: 'var(--warning-bg)', color: 'var(--warning)',
verticalAlign: 'middle',
}}>
{d.note}
</span>
)}
{active && (
<span style={{
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import NewAppointmentModal from './NewAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const slot = {
start: 1_780_000_000,
end: 1_780_001_800,
start_time: '۰۹:۰۰',
end_time: '۰۹:۳۰',
doctor_uuid: 'doc-1',
doctor_name: 'دکتر رضایی',
};
beforeEach(() => {
get.mockReset();
get.mockResolvedValue({ success: true, data: [] });
});
const props = (overrides: Record<string, unknown> = {}) => ({
slot,
onClose: vi.fn(),
onSuccess: vi.fn(),
...overrides,
});
/** عنوان مرحله‌های نوار بالای مودال. */
const stepLabels = () =>
Array.from(document.querySelectorAll('ol[aria-label="مراحل ثبت نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim());
const pickerProps = () => props({ serviceMode: true, date: '1405-05-18', services: [] });
describe('NewAppointmentModal — ویزارد', () => {
/**
* فرم قبلاً یک صفحهٔ بلند بود: خدمت و زمان، بیمار و هزینه با هم. هر بار یک مرحله
* دیده میشود تا مودال از ارتفاع صفحه بلندتر نشود.
*/
it('حالت انتخابگر سه مرحله دارد و از مرحلهٔ اول شروع می‌شود', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
expect(stepLabels()).toEqual(['خدمت و زمان', 'بیمار', 'تأیید و ثبت']);
expect(screen.getByRole('heading', { name: 'خدمت و زمان' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'بیمار' })).not.toBeInTheDocument();
});
it('مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود', () => {
// نوبت اسلاتی: زمان از خودِ اسلات می‌آید، پس مرحلهٔ «خدمت و زمان» بی‌معناست.
renderWithProviders(<NewAppointmentModal {...props()} />);
expect(stepLabels()).toEqual(['بیمار', 'تأیید و ثبت']);
});
it('بیمارِ از پیش معلوم، مرحلهٔ بیمار را حذف می‌کند', () => {
renderWithProviders(<NewAppointmentModal {...props({
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
})} />);
// تنها مرحلهٔ باقی‌مانده «تأیید و ثبت» است، پس نوار مراحل هم لازم نیست.
expect(document.querySelector('ol[aria-label="مراحل ثبت نوبت"]')).toBeNull();
expect(screen.getByRole('heading', { name: 'تأیید و ثبت' })).toBeInTheDocument();
});
it('تا وقتی مرحله کامل نشده، دکمهٔ بعدی غیرفعال است و دلیلش را می‌گوید', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
expect(screen.getByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
// دکمهٔ ثبت فقط در مرحلهٔ آخر وجود دارد.
expect(screen.queryByRole('button', { name: 'ثبت نوبت' })).not.toBeInTheDocument();
});
it('در مرحلهٔ اول دکمهٔ «مرحلهٔ قبل» نیست، ولی «انصراف» همیشه هست', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ آخر خلاصهٔ نوبت را نشان می‌دهد', () => {
renderWithProviders(<NewAppointmentModal {...props({
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
})} />);
expect(screen.getByText('علی محمدی')).toBeInTheDocument();
expect(screen.getByText('09123456789')).toBeInTheDocument();
});
it('دکمهٔ اصلی در فوتر ثابت است، نه داخل ناحیهٔ اسکرول', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
const next = screen.getByRole('button', { name: 'مرحلهٔ بعد' });
expect(next.closest('.modal-foot')).not.toBeNull();
expect(next.closest('.modal-body')).toBeNull();
});
});
@@ -0,0 +1,692 @@
import React, { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils';
import { useAuthStore } from '../../stores/authStore';
import Modal from '../ui/Modal';
import PersianDateInput from '../ui/PersianDateInput';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import Stepper from '../ui/Stepper';
import ServiceSlotPicker from './ServiceSlotPicker';
import type { ServicePick } from './ServiceSlotPicker';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
export interface BookingSlot {
start: number;
end: number;
start_time: string;
end_time: string;
doctor_uuid: string;
doctor_name: string;
}
type StepKey = 'service' | 'patient' | 'confirm';
const STEP_TITLES: Record<StepKey, string> = {
service: 'خدمت و زمان',
patient: 'بیمار',
confirm: 'تأیید و ثبت',
};
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */
export interface BookingResource {
uuid: string;
name: string;
}
/**
* ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویسها و زمان، هزینهٔ ویزیت.
*
* سه حالت دارد و هر سه یک فرماند سه مودال یعنی سه رفتار:
* - اسلاتی: زمان همان اسلاتِ کلیکشده است.
* - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک میآید.
* - منبع: همان انتخابگر، ولی زمانها از تقویم خودِ منبع و ثبت با `resource_uuid`.
*/
export default function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
treatmentSessionUuid = null, patient = null,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
resource?: BookingResource | null;
/**
* رزروِ صریحِ یک جلسهٔ درمان. بیمار میتواند چند دورهٔ باز داشته باشد، پس بدون این،
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
*/
treatmentSessionUuid?: string | null;
/**
* بیمارِ از پیش معلوم وقتی مودال از پروندهٔ خودِ بیمار باز میشود.
*
* مرحلهٔ جستجو رد می‌شود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را
* دوباره با کد ملی پیدا کند.
*/
patient?: { name: string | null; mobile: string; national_code: string | null } | null;
}) {
const [mobile, setMobile] = useState(patient?.mobile ?? '');
const [lookup, setLookup] = useState<PatientLookup | null>(
patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code },
);
const [patientName, setPatientName] = useState(patient?.name ?? '');
const [nationalCode, setNationalCode] = useState(patient?.national_code ?? '');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد،
// پر کردنش آن را از قبل به یک نفر می‌دهد.
const [staffUuid, setStaffUuid] = useState('');
/**
* تاریخ داخل خودِ مودال قابل ویرایش است.
*
* prop فقط نقطهٔ شروع را میدهد تاریخِ کارتِ جلسه یا روزِ انتخابشدهٔ صفحه. ولی
* وقت پیشنهادی همیشه در دسترس نیست و کاربر باید بتواند همانجا روز دیگری بگیرد،
* بدون بستن فرمی که بیمار و سرویسش را پر کرده.
*/
const [activeDate, setActiveDate] = useState(date ?? '');
useEffect(() => { setActiveDate(date ?? ''); }, [date]);
// پیش‌فرضِ پروتکل فقط تا وقتی اعمال می‌شود که کاربر دست نزده باشد؛ وگرنه انتخاب
// دستیِ منشی با هر تغییرِ سرویس پاک می‌شد.
const [staffTouched, setStaffTouched] = useState(false);
const role = useAuthStore(s => s.primaryRole);
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد.
const createEndpoint = role === 'admin' && resource === null
? '/api/v1/admin/appointment'
: '/api/v1/my/appointment';
// منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد
// که بشود رویش نشست.
const pickerMode = serviceMode || resource !== null;
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
//
// قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود.
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
const doctorPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
enabled: !!slot.doctor_uuid,
retry: false,
});
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
queryKey: ['staff-list'],
queryFn: () => api.get('/api/v1/staff'),
enabled: pickerMode,
staleTime: 60_000,
});
/**
* پرسنلِ مجازِ «طول درمان» همان سرویس.
*
* منشی نباید چیزی را دوباره انتخاب کند که در تنظیمات سرویس یک بار تعریف شده؛
* پیشفرض همان است و تغییرش آزاد.
*/
const primaryService = pick.serviceUuids[0] ?? '';
const protocolQ = useQuery<ApiResponse<{ staff: { uuid: string; name: string }[] } | null>>({
queryKey: ['service-protocol', primaryService],
queryFn: () => api.get(`/api/v1/service-item/${primaryService}/treatment-protocol`),
enabled: primaryService !== '',
staleTime: 60_000,
});
const protocolStaff = protocolQ.data?.data?.staff ?? [];
useEffect(() => {
if (staffTouched || protocolStaff.length === 0) return;
setStaffUuid(protocolStaff[0].uuid);
}, [protocolStaff, staffTouched]);
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled: doctorPricingQ.isError || !slot.doctor_uuid,
});
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
const requireVisit = pricing?.data?.require_visit_price ?? false;
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const [stepIdx, setStepIdx] = useState(0);
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
// اعتبار کلید جستجو بسته به معیار انتخاب‌شده.
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const visitPriceValid = !requireVisit || visitPriceToman > 0;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
const search = useMutation({
mutationFn: () => {
const q = searchBy === 'mobile'
? `mobile=${encodeURIComponent(mobile)}`
: `national_code=${encodeURIComponent(nationalCode)}`;
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
},
onSuccess: (res: any) => {
const data: PatientLookup = res?.data ?? { found: false };
setLookup(data);
setPatientName(data.found ? (data.name ?? '') : '');
// موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند.
if (data.found) {
if (data.mobile) setMobile(data.mobile);
setNationalCode(data.national_code ?? '');
} else if (searchBy === 'mobile') {
setNationalCode('');
}
},
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
});
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}),
slot_start: pickerMode ? pick.slot!.start : slot.start,
slot_end: pickerMode ? pick.slot!.end : slot.end,
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}),
...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}),
// منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست.
...(resource ? {
resource_uuid: resource.uuid,
duration_from_services: true,
service_durations: pick.durations,
} : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
onSuccess();
onClose();
},
onError: (e: any) => {
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
// تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند.
function invalidateLookup() {
if (lookup !== null) { setLookup(null); setPatientName(''); }
}
function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
}
function onNationalSearchChange(v: string) {
setNationalCode(digitsOnly(v, 10));
invalidateLookup();
}
// جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند.
function onSwitchSearchBy(mode: 'mobile' | 'national') {
setSearchBy(mode);
setLookup(null); setPatientName('');
setMobile(''); setNationalCode('');
}
// هدفِ نوبت به‌صورت «برچسب: مقدار» — دو اسمِ لخت کنار هم معلوم نمی‌کند کدام دستگاه
// است و کدام پزشک. تاریخ هم اینجاست چون تنها جای مودال بود که اصلاً دیده نمی‌شد.
const targetFacts: { label: string; value: string }[] = [
resource
? { label: 'دستگاه', value: resource.name }
: pickerMode
? { label: 'پزشک', value: slot.doctor_name }
: { label: 'ساعت', value: `${slot.start_time} تا ${slot.end_time}` },
...((resource || !pickerMode) && slot.doctor_name
? [{ label: resource ? 'پزشک ناظر' : 'پزشک', value: slot.doctor_name }]
: []),
/* در حالت انتخابگر، خودِ فیلدِ تاریخ همین را می‌گوید و تکرارش در هدر نویز است. */
...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
];
/**
* مراحلِ ویزارد فقط مراحلی که واقعاً تصمیمی دارند.
*
* فرم قبلاً یک صفحهٔ بلند بود: انتخاب سرویس و زمان، جستجوی بیمار و هزینه همه با هم.
* مرحلهای که دادهاش از قبل معلوم است ساخته نمیشود نوبتِ اسلاتی زمان دارد و
* فرمِ بازشده از پروندهٔ بیمار، بیمار.
*/
const stepKeys: StepKey[] = [
...(pickerMode && activeDate ? (['service'] as StepKey[]) : []),
...(patient === null ? (['patient'] as StepKey[]) : []),
'confirm',
];
const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)];
const isLastStep = currentStep === 'confirm';
// اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ
// بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
const stepBlockReason: Record<StepKey, string | null> = {
service: !serviceTimingValid
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
: null,
patient: lookup === null
? 'ابتدا بیمار را جستجو کنید'
: needsDetails && !detailsValid
? 'مشخصات بیمار را کامل کنید'
: !mobileValid
? 'شماره موبایل بیمار معتبر نیست'
: null,
confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null,
};
const blockReason = stepBlockReason[currentStep];
// خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت می‌شود، پیش از ثبت.
const pickedServiceNames = pick.serviceUuids
.map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid);
const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null;
const summaryRows: { label: string; value: string }[] = [
...targetFacts.map(f => ({ label: f.label, value: f.value })),
...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []),
...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []),
...(staffName ? [{ label: 'پرسنل', value: staffName }] : []),
{ label: 'بیمار', value: effectiveName || (patient?.name ?? '—') },
{ label: 'موبایل', value: mobile || (patient?.mobile ?? '—') },
];
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return (
<Modal
open
title="ثبت نوبت"
// هر بار یک مرحله دیده می‌شود، پس ستون تکی کافی است؛ `md` عرضِ راحتِ
// انتخابگر سرویس و اسلات است بدون فضای خالی.
size="md"
onClose={onClose}
footer={
<>
{/* «قبلی» جای «انصراف» را نمی‌گیرد: بستنِ فرم همیشه باید یک کلیک باشد. */}
<button className="btn ghost" onClick={onClose}>انصراف</button>
{stepIdx > 0 && (
<button className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
مرحلهٔ قبل
</button>
)}
{isLastStep ? (
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
) : (
<button
className="btn primary"
onClick={() => setStepIdx(i => i + 1)}
disabled={blockReason !== null}
>
مرحلهٔ بعد
</button>
)}
</>
}
>
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
<div style={{
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
{resource
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
{targetFacts.map(f => (
<span key={f.label} style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>{f.label}: </span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{f.value}</span>
</span>
))}
</div>
{stepKeys.length > 1 && (
<Stepper
steps={stepKeys.map(key => ({ key, title: STEP_TITLES[key] }))}
current={currentStep}
ariaLabel="مراحل ثبت نوبت"
/>
)}
{currentStep === 'service' && (
<Step title={STEP_TITLES.service}>
<div className="field-block" style={{ marginBottom: 12, maxWidth: 220 }}>
<label htmlFor="appt-date">تاریخ نوبت</label>
<PersianDateInput
value={activeDate}
onChange={setActiveDate}
ariaLabel="تاریخ نوبت"
/>
</div>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
resourceUuid={resource?.uuid}
date={activeDate}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
{/* اختیاری بهعمد: پیشفرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت
میکند که بیمار اپراتور مشخصی خواسته باشد. */}
<div className="field-block" style={{ marginTop: 12 }}>
<label htmlFor="appt-operator">پرسنل <span className="opt">(اختیاری)</span></label>
<SearchableSelect
inputId="appt-operator"
options={(staffQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.full_name }))}
value={staffUuid || null}
onChange={v => { setStaffUuid(v ? String(v) : ''); setStaffTouched(true); }}
placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'}
isLoading={staffQ.isLoading}
isClearable
height={40}
/>
</div>
</Step>
)}
{currentStep === 'patient' && (
<Step title={STEP_TITLES.patient}>
{/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */}
{patient !== null && (
<div style={{
marginBottom: 14, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{patient.name || 'بدون نام'}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12, direction: 'ltr', textAlign: 'start' }}>
{patient.mobile}{patient.national_code ? ` · ${patient.national_code}` : ''}
</div>
</div>
</div>
)}
<div className="field-block" style={{ marginBottom: 14, display: patient === null ? undefined : 'none' }}>
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={searchBy === mode ? 'on' : ''}
aria-pressed={searchBy === mode}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1, justifyContent: 'center' }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
id="appt-patient-search"
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus={!pickerMode}
/>
) : (
<input
id="appt-patient-search"
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
// در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار
// اینجا کاربر را از مرحلهٔ یک رد می‌کرد.
autoFocus={!pickerMode}
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را می‌گوید؛ دو بار گفتنش نویز است. */}
{patient === null && foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div>
</div>
)}
{needsDetails && (
<>
<div style={{
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-name">نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-name"
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
/>
</div>
</div>
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-mobile">شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-mobile"
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-national">کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-national"
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
</>
)}
</Step>
)}
{currentStep === 'confirm' && (
<Step title={STEP_TITLES.confirm}>
{/* خلاصهٔ همان چیزی که ثبت میشود تنها جایی که کاربر پیش از ثبت، انتخاب
سرویس و زمان و بیمار را با هم میبیند. */}
<dl style={{
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 14px', margin: '0 0 18px',
padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
fontSize: 13,
}}>
{summaryRows.map(row => (
<React.Fragment key={row.label}>
<dt style={{ color: 'var(--text-3)' }}>{row.label}</dt>
<dd style={{ margin: 0, color: 'var(--text)', fontWeight: 600, minWidth: 0, overflowWrap: 'anywhere' }}>
{row.value}
</dd>
</React.Fragment>
))}
</dl>
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
<div className="field-block">
{requireVisit ? (
<label>
هزینه ویزیت (تومان)<span className="req"> *</span>
</label>
) : (
// سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است).
<button
type="button"
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
// سرِ کلپس تمام عرض و ۳۶px است: با padding صفر ارتفاعش اندازهٔ یک خط متن
// می‌شد و روی موبایل عملاً قابل زدن نبود.
display: 'flex', alignItems: 'center', gap: 6, width: '100%', minHeight: 36,
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)', textAlign: 'start',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
<ChevronDownIcon
style={{
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)',
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
}}
/>
</button>
)}
{visitPriceExpanded && (
<>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
suffix="تومان"
/>
</div>
{requireVisit && visitPriceToman <= 0
? <span className="field-err">هزینه ویزیت الزامی است</span>
: <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button
type="button"
className="btn ghost sm"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
>
استفاده از تعرفهٔ پزشک
</button>
)}
</>
)}
</div>
</Step>
)}
{blockReason && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 16,
fontSize: 12.5, color: 'var(--text-3)',
}}>
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
</div>
)}
</Modal>
);
}
/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */
function Step({ title, children }: { title: string; children: ReactNode }) {
return (
<section style={{ marginBottom: 18 }}>
<h3 style={{
margin: '0 0 10px', fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
}}>
{title}
</h3>
{children}
</section>
);
}
@@ -0,0 +1,261 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import { useAuthStore } from '../../stores/authStore';
import NewAppointmentModal from './NewAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const START = 1_900_000_000;
const slot = {
start: 0, end: 0, start_time: '', end_time: '',
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
};
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
const services = [
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
];
/** بیمارِ یافت‌شده تا فرم معتبر شود؛ زمان‌های خالی از تقویم منبع. */
function mockApi() {
get.mockImplementation((url: string) => {
if (url.includes('patient-lookup')) {
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
}
if (url === '/api/v1/staff') {
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
}
if (url.includes('/treatment-protocol')) {
return Promise.resolve({ success: true, data: { staff: [{ uuid: 'st-1', name: 'پرسنل یک' }] } });
}
if (url.includes('/service-slots')) {
return Promise.resolve({ success: true, data: {
total_duration_minutes: 25,
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
} });
}
return Promise.resolve({ success: true, data: {} });
});
}
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
async function pickServiceAndTime() {
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش می‌رویم.
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
// «انتخاب‌شده/نشده» اعلام شود.
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
}
/** فرم ویزارد شد: هر «مرحلهٔ بعد» یک گام جلو می‌برد. */
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
/** خدمت و زمان را انتخاب می‌کند و به مرحلهٔ «بیمار» می‌رود. */
async function pickServiceAndTimeThenNext() {
await pickServiceAndTime();
nextStep();
}
beforeEach(() => {
get.mockReset();
post.mockReset();
useAuthStore.setState({ primaryRole: 'clinic' } as any);
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
mockApi();
});
describe('مودال ثبت نوبتِ منبع', () => {
it('نام منبع و پزشک ناظر بالای فرم می‌آید و زمان‌ها از تقویم منبع خوانده می‌شوند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
await pickServiceAndTime();
await waitFor(() => expect(
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
).toBe(true));
});
it('ثبت، نوبت را با resource_uuid و مدتِ سرویس‌ها می‌فرستد', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTimeThenNext();
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
nextStep();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalled());
const [url, body] = post.mock.calls[0];
expect(url).toBe('/api/v1/my/appointment');
expect(body).toMatchObject({
resource_uuid: 'r-2',
duration_from_services: true,
service_item_uuids: ['s-1'],
service_durations: { 's-1': 25 },
slot_start: START,
patient_national_code: '0012345678',
});
});
it('بدون انتخاب سرویس و زمان، از مرحلهٔ اول رد نمی‌شود', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
// مرحلهٔ بیمار هنوز ساخته نشده، پس فیلد جستجو در DOM نیست.
expect(screen.queryByPlaceholderText('کد ملی ۱۰ رقمی')).toBeNull();
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
});
it('دلیلِ غیرفعال بودنِ ثبت را می‌گوید و با پیشرفتِ فرم عوض می‌شود', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش می‌رویم.
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
// سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
nextStep();
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
});
/** تاریخ در حالت انتخابگر یک فیلدِ قابل ویرایش است، نه متنِ ثابتِ هدر. */
it('تاریخ نوبت به‌صورت جلالی و قابل ویرایش می‌آید', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
// چیپ هدر دیگر تاریخ را تکرار نمی‌کند.
expect(screen.queryByText('تاریخ:')).not.toBeInTheDocument();
});
it('معیار جستجو یک seg با حالتِ اعلام‌شده است', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTimeThenNext();
const national = screen.getByRole('button', { name: 'کد ملی' });
const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
expect(national).toHaveAttribute('aria-pressed', 'true');
expect(mobile).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(mobile);
expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
});
/** آنچه در «طول درمان» سرویس تعریف شده نباید دوباره از منشی پرسیده شود. */
it('پرسنلِ پروتکل سرویس را پیش‌فرض می‌فرستد', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTime();
// پرسنل در همان مرحلهٔ «خدمت و زمان» است.
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
nextStep();
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
nextStep();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' });
});
/** پروتکل بدون پرسنل نباید فرم را قفل کند. */
it('پروتکل بدون پرسنل، فیلد را خالی می‌گذارد و ثبت همچنان ممکن است', async () => {
get.mockImplementation((url: string) => {
if (url.includes('/treatment-protocol')) return Promise.resolve({ success: true, data: null });
if (url.includes('patient-lookup')) {
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
}
if (url.includes('/service-slots')) {
return Promise.resolve({ success: true, data: {
total_duration_minutes: 25,
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
} });
}
return Promise.resolve({ success: true, data: [] });
});
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTimeThenNext();
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
nextStep();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
});
/**
* وقتِ پیشنهادی همیشه در دسترس نیست؛ کاربر باید بتواند بدون بستن فرم روز دیگری
* بگیرد و زمانها برای همان روز دوباره خوانده شوند.
*/
it('تغییر تاریخ، زمان‌های خالی را برای روز تازه می‌خواند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTime();
await waitFor(() => expect(
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('date=2026-08-04')),
).toBe(true));
// `PersianDateInput` تریگرش input نیست؛ با نام دسترس‌پذیرش پیدایش می‌کنیم.
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
});
it('منبعِ بدون سرویس، پیام راهنما می‌دهد نه فهرست خالی', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import ResourceDayPanel from './ResourceDayPanel';
import type { Appointment, ClinicResource } from '../../types';
const get = api.get as ReturnType<typeof vi.fn>;
const DAY = Math.floor(Date.now() / 1000) + 7 * 86400;
const resource = {
uuid: 'r-2', name: 'لیزر CO2',
supervisor: { uuid: 'd1', name: 'مینا یوسفی' },
} as unknown as ClinicResource;
const appointment = {
uuid: 'a-1', patient_name: 'رضا رحیمی', patient_mobile: '09120001307',
doctor_uuid: 'd1', doctor_name: 'مینا یوسفی',
slot_start: DAY + 3600, slot_end: DAY + 3600 + 3000,
appointment_date: '', appointment_time: '10:35', end_time: '11:25',
status: 'pending', version: 1, created_at: '',
service_item: { uuid: 's-2', name: 'RF فرکشنال' },
} as unknown as Appointment;
function render(appointments: Appointment[] = []) {
return renderWithProviders(
<ResourceDayPanel
resource={resource}
date="2026-08-04"
appointments={appointments}
loading={false}
canCreate
queryKey={['appointments']}
onBook={() => {}}
onView={() => {}}
/>,
);
}
beforeEach(() => get.mockReset());
describe('ResourceDayPanel', () => {
it('بازهٔ کاری منبع و ردیف‌های خالیِ قابل رزرو را نشان می‌دهد', async () => {
get.mockResolvedValue({ success: true, data: {
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
empty_reason: null,
} });
render();
expect(await screen.findByText(/ساعت کاری: 08:00 - 14:00/)).toBeInTheDocument();
expect(screen.getByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
});
it('نوبتِ رزروشده کارت خودش را می‌گیرد و خالی‌ها دو طرفش می‌مانند', async () => {
get.mockResolvedValue({ success: true, data: {
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
empty_reason: null,
} });
render([appointment]);
expect(await screen.findByText('رضا رحیمی')).toBeInTheDocument();
expect(screen.getByText('سرویس: RF فرکشنال')).toBeInTheDocument();
expect(screen.getAllByText('افزودن نوبت سریع')).toHaveLength(2);
});
it('روزِ بدون شیفت، دلیلش را می‌گوید نه فهرست خالی', async () => {
get.mockResolvedValue({ success: true, data: { windows: [], empty_reason: 'no_shift' } });
render();
expect(await screen.findByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
});
});
@@ -0,0 +1,79 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import TurnsTimeline from './TurnsTimeline';
import { buildServiceTimeline } from './serviceTimeline';
import type { TimelineSlot } from './types';
import type { Appointment, ClinicResource } from '../../types';
interface DaySlotsData {
windows: { start: number; end: number; start_time: string; end_time: string }[];
empty_reason: string | null;
}
/**
* نمای روزِ یک منبع همان تایملاینِ نوبتدهی سرویسیِ پزشک، با تقویم خودِ منبع.
*
* منبع اسلاتِ ثابت ندارد: بازهٔ کاری از شیفت خودش میآید و طول هر نوبت از سرویسهایش،
* پس ردیفها «نوبتهای رزروشده + بازههای خالیِ بینشان»اند. کامپوننت تایملاین یکی
* است تا کارت، وضعیت و عملیاتِ نوبت در هر دو نما یک چیز باشند.
*/
export default function ResourceDayPanel({
resource, date, appointments, loading, canCreate, queryKey, onBook, onView,
}: {
resource: ClinicResource;
/** روزِ نمایش، ISO `Y-m-d`. */
date: string;
appointments: Appointment[];
loading: boolean;
canCreate: boolean;
queryKey: unknown[];
onBook: (slot: TimelineSlot | null) => void;
onView: (appointment: Appointment) => void;
}) {
const dayQuery = useQuery<ApiResponse<DaySlotsData>>({
queryKey: ['resource-day-slots', resource.uuid, date],
queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/day-slots?date=${date}`),
});
const data = dayQuery.data?.data;
const windows = data?.windows ?? [];
const slots = React.useMemo(
() => buildServiceTimeline(windows, appointments),
[windows, appointments],
);
const workingRange = windows.length > 0
? { start: windows[0].start_time, end: windows[windows.length - 1].end_time }
: null;
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)',
fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>نوبتدهی سرویسی نوبتها بر اساس مدت سرویس چیده میشوند.</span>
{workingRange && (
<span dir="ltr" style={{ color: 'var(--text-3)', flexShrink: 0 }}>
ساعت کاری: {workingRange.start} - {workingRange.end}
</span>
)}
</div>
<TurnsTimeline
slots={canCreate ? slots : slots.filter(s => s.appointment !== null)}
loading={loading || dayQuery.isLoading}
queryKey={queryKey}
onView={onView}
onBook={onBook}
emptyReason={data?.empty_reason ?? null}
errorMessage={dayQuery.isError ? ((dayQuery.error as Error)?.message || 'خطای نامشخص') : null}
/>
</div>
);
}
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from '../ui/SearchableSelect';
import DigitInput from '../ui/DigitInput';
@@ -18,20 +19,35 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
*/
export default function ServiceSlotPicker({
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
excludeAppointmentUuid, initialSelection,
}: {
doctorUuid: string;
/**
* نوبتدهی روی یک منبع: زمان‌ها از تقویم خودِ منبع میآیند، نه از برنامهٔ پزشک.
*
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش سرویس مدت زمان)، پس فقط
* منبعِ زمان عوض میشود نه کامپوننت دو نسخه یعنی دو رفتار.
*/
resourceUuid?: string;
date: string;
services: BookingService[];
onSelect: (v: ServicePick) => void;
editableDuration?: boolean;
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */
clinicUuidOverride?: string | null;
/**
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلیاش در فهرست
* نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
*/
excludeAppointmentUuid?: string;
/** سرویس‌های از قبل انتخاب‌شده (ویرایش نوبت موجود). فقط یک بار هیدریت می‌شود. */
initialSelection?: PickedService[];
}) {
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>([]);
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
// بخش‌های یکتا از روی سرویس‌های bookable (بدون endpoint اضافه — همه یکجا آمده‌اند).
@@ -45,9 +61,14 @@ export default function ServiceSlotPicker({
[services, sectionUuid],
);
// تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند.
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
// تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند. اجرای نخست معاف است،
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک می‌شد.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) { mounted.current = true; return; }
setSelected([]); setSectionUuid('');
}, [doctorUuid, resourceUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
const durations = useMemo(
@@ -58,15 +79,19 @@ export default function ServiceSlotPicker({
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
resourceUuid
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ servicesQs
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
enabled: (!!resourceUuid || !!doctorUuid) && !!date && serviceUuids.length > 0,
});
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
@@ -84,7 +109,9 @@ export default function ServiceSlotPicker({
if (services.length === 0) {
return (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
{resourceUuid
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید.'
: 'سرویسی با «نمایش در نوبت‌دهی آنلاین» برای این پزشک تعریف نشده است.'}
</div>
);
}
@@ -92,7 +119,7 @@ export default function ServiceSlotPicker({
return (
<div>
{/* انتخاب بخش */}
<label style={label}>بخش</label>
<label style={label} htmlFor="service-mode-section-select">بخش</label>
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
<SearchableSelect
inputId="service-mode-section-select"
@@ -117,9 +144,10 @@ export default function ServiceSlotPicker({
const active = selected.some(p => p.uuid === s.uuid);
return (
<button key={s.uuid} type="button" onClick={() => toggle(s)}
role="checkbox" aria-checked={active}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
minHeight: 40, padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
fontFamily: 'inherit', fontSize: 13,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary-soft)' : 'var(--surface)',
@@ -164,7 +192,7 @@ export default function ServiceSlotPicker({
{editableDuration ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
height: 36, padding: '0 8px', borderRadius: 'var(--r-sm)',
background: 'var(--surface)', border: '1px solid var(--border-2)',
}}>
<DigitInput
@@ -179,12 +207,12 @@ export default function ServiceSlotPicker({
) : (
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
)}
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
style={{
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: 'var(--on-primary)',
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
{/* هدف کلیک ۳۲px است نه ۱۸px این دکمه انتخابِ کاربر را پاک میکند و
خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */}
<button type="button" className="mini-btn" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
style={{ flexShrink: 0, color: 'var(--primary-700)' }}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
))}
</div>
@@ -197,6 +225,13 @@ export default function ServiceSlotPicker({
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
{slotsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
) : slotsQ.isError ? (
/* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد یعنی کاربر
روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' }}>
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن زمانهای خالی ناموفق بود.</span>
<button type="button" className="btn ghost sm" onClick={() => slotsQ.refetch()}>تلاش دوباره</button>
</div>
) : startTimes.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
@@ -207,9 +242,10 @@ export default function ServiceSlotPicker({
const active = pickedSlot?.start === s.start;
return (
<button key={s.start} type="button" dir="ltr"
aria-pressed={active}
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
style={{
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? 'var(--on-primary)' : 'var(--text)',
}}>
@@ -187,6 +187,12 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' },
// دلایلِ تقویمِ منبع — `resource/{uuid}/day-slots`.
no_shift: { title: 'این روز شیفت کاری ندارد', hint: 'در تقویم این منبع، برای این روز شیفتی تعریف نشده است' },
national_holiday: { title: 'تعطیل رسمی', hint: 'این روز در تقویم رسمی تعطیل است' },
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبت‌دهی، منبع را از صفحهٔ «منابع» فعال کنید' },
};
export default function TurnsTimeline({
@@ -1,51 +1,64 @@
/**
* سوییچ کشویی «نمایش جدولی / زمانبندی» بازسازی `TurnsViewModeToggle.jsx` طرح
* tauri (کادر ۱۹۳×۴۸، پسزمینهٔ لغزنده، متن فعال #5559ce).
* سوییچ کشویی نمای نوبتها بازسازی `TurnsViewModeToggle.jsx` طرح tauri
* (پسزمینهٔ لغزنده، متن فعال #5559ce).
*
* منابع نمای جدا ندارند: ردیفشان درون همان نمای «زمانبندی» میآید، چون پرشدن یک ساعت
* را دستگاه و اتاق تعیین میکنند نه فقط برنامهٔ پزشک و دیدنشان جدا از هم یعنی کاربر
* باید دو نما را با چشم تطبیق دهد.
*/
export type TurnsViewMode = 'table' | 'timeline';
const MODES: { id: TurnsViewMode; label: string }[] = [
{ id: 'table', label: 'نمایش جدولی' },
{ id: 'timeline', label: 'زمانبندی' },
];
export default function TurnsViewToggle({
viewMode, onChange,
}: {
viewMode: TurnsViewMode;
onChange: (m: TurnsViewMode) => void;
}) {
const activeText = 'var(--primary)';
const idleText = 'var(--text-3)';
const index = Math.max(MODES.findIndex((m) => m.id === viewMode), 0);
const width = 100 / MODES.length;
return (
<div style={{
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}>
{/* پس‌زمینهٔ لغزنده */}
<div
role="tablist"
aria-label="نمای نوبت‌ها"
style={{
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}
>
{/* پس‌زمینهٔ لغزنده — RTL، پس اولین حالت سمت راست است */}
<div style={{
position: 'absolute', top: 0, right: 0, height: '100%', width: '50%',
position: 'absolute', top: 0, right: 0, height: '100%', width: `${width}%`,
background: 'var(--primary-soft)', transition: 'transform .3s var(--ease)',
transform: viewMode === 'table' ? 'translateX(100%)' : 'translateX(0%)',
transform: `translateX(${index * -100}%)`,
}} />
<button
type="button"
onClick={() => onChange('table')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'table' ? activeText : idleText,
}}
>
نمایش جدولی
</button>
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
<button
type="button"
onClick={() => onChange('timeline')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'timeline' ? activeText : idleText,
}}
>
زمانبندی
</button>
{MODES.map((m, i) => (
<div key={m.id} style={{ display: 'contents' }}>
{i > 0 && (
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
)}
<button
type="button"
role="tab"
aria-selected={viewMode === m.id}
onClick={() => onChange(m.id)}
style={{
position: 'relative', zIndex: 1, flex: 1, height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500,
color: viewMode === m.id ? 'var(--primary)' : 'var(--text-3)',
}}
>
{m.label}
</button>
</div>
))}
</div>
);
}
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { buildServiceTimeline } from './serviceTimeline';
import type { Appointment } from '../../types';
const DAY = 1_800_000_000; // نیمه‌شبِ فرضی
const at = (h: number) => DAY + h * 3600;
function appointment(from: number, to: number, over: Partial<Appointment> = {}): Appointment {
return {
uuid: `a-${from}`, patient_name: 'بیمار', patient_mobile: '09120000000',
doctor_uuid: 'd1', doctor_name: 'دکتر', slot_start: from, slot_end: to,
appointment_date: '', appointment_time: '', end_time: '',
status: 'confirmed', version: 1, created_at: '',
...over,
} as Appointment;
}
describe('buildServiceTimeline', () => {
const window = [{ start: at(8), end: at(14) }];
it('یک نوبت، بازهٔ کاری را به «خالی — نوبت — خالی» می‌شکند', () => {
const rows = buildServiceTimeline(window, [appointment(at(10), at(11))], at(0));
expect(rows.map(r => [r.start, r.end, r.appointment !== null])).toEqual([
[at(8), at(10), false],
[at(10), at(11), true],
[at(11), at(14), false],
]);
});
it('بدون نوبت، کل بازهٔ کاری یک ردیفِ خالی است', () => {
const rows = buildServiceTimeline(window, [], at(0));
expect(rows).toHaveLength(1);
expect(rows[0].is_available).toBe(true);
});
it('نوبت لغوشده جای خالی را نمی‌گیرد', () => {
const rows = buildServiceTimeline(
window,
[appointment(at(10), at(11), { status: 'cancelled_by_doctor' })],
at(0),
);
expect(rows).toHaveLength(1);
expect(rows[0].appointment).toBeNull();
});
it('نوبتِ رزرو (روزانه) بازه‌ای اشغال نمی‌کند', () => {
const rows = buildServiceTimeline(window, [appointment(at(10), at(11), { is_reserve: true })], at(0));
expect(rows).toHaveLength(1);
expect(rows[0].appointment).toBeNull();
});
it('بازهٔ خالیِ گذشته به «اکنون» بریده می‌شود و ردیفِ تمام‌گذشته حذف', () => {
const rows = buildServiceTimeline([{ start: at(8), end: at(9) }, { start: at(10), end: at(14) }], [], at(11));
expect(rows).toHaveLength(1);
expect(rows[0].start).toBe(at(11));
expect(rows[0].end).toBe(at(14));
});
it('نوبتِ خارج از بازهٔ کاری، ردیف نمی‌سازد', () => {
const rows = buildServiceTimeline(window, [appointment(at(20), at(21))], at(0));
expect(rows.every(r => r.appointment === null)).toBe(true);
});
});
@@ -0,0 +1,67 @@
import { formatTime } from '../../lib/utils';
import { CANCELLED_STATUSES } from './turnStatus';
import type { Appointment } from '../../types';
import type { TimelineSlot } from './types';
/** یک بازهٔ کاری: شیفتِ منبع یا سشنِ برنامهٔ هفتگیِ پزشک. */
export interface WorkingWindow {
start: number;
end: number;
}
/**
* تایملاینِ نوبتدهی **سرویسی**: نوبتهای رزروشده + بازههای خالیِ بینشان.
*
* حالت سرویسی اسلاتِ ثابت ندارد طول هر نوبت از سرویسهایش میآید پس ردیفها از
* روی بازهٔ کاری و نوبتهای واقعی ساخته میشوند، نه از شبکهٔ اسلات.
*
* پزشک و منبع همین یک الگوریتم را دارند: بازهٔ کاری یکی از برنامهٔ هفتگی میآید و
* دیگری از تقویم منبع، ولی چیدنِ کارتها فرقی نمیکند و دو نسخهاش یعنی دو رفتار.
*/
export function buildServiceTimeline(
windows: WorkingWindow[],
appointments: Appointment[],
now: number = Math.floor(Date.now() / 1000),
): TimelineSlot[] {
const booked = appointments
.filter(a => !CANCELLED_STATUSES.has(a.status) && !a.is_reserve)
.map(a => ({
appointment: a,
start: Number(a.slot_start),
end: Number(a.slot_end),
}))
.sort((a, b) => a.start - b.start);
const out: TimelineSlot[] = [];
/** بازهٔ خالی؛ تکهٔ گذشته‌اش بریده می‌شود چون قابل رزرو نیست. */
const pushFree = (start: number, end: number) => {
const from = start < now ? now : start;
if (end <= from) return;
out.push({
start: from, end,
start_time: formatTime(from), end_time: formatTime(end),
is_available: true, appointment: null, cancelled_appointment: null,
});
};
windows.forEach(({ start: winStart, end: winEnd }) => {
let cursor = winStart;
booked
.filter(b => b.start >= winStart && b.start < winEnd)
.forEach(({ appointment, start, end }) => {
if (start > cursor) pushFree(cursor, start);
out.push({
start, end,
start_time: formatTime(start), end_time: formatTime(end),
is_available: false, appointment, cancelled_appointment: null,
});
cursor = Math.max(cursor, end);
});
if (cursor < winEnd) pushFree(cursor, winEnd);
});
return out;
}
@@ -9,7 +9,7 @@
* and the parent passes the query key to invalidate.
*/
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
import { findRecordUuid } from '../AppointmentActions';
@@ -11,6 +11,40 @@ const real: ChartPoint[] = [
const EMPTY = 'داده‌ای برای نمایش نیست';
/**
* صفحه RTL است، پس در یک ردیفِ flex «اولین فرزند» سمت راست رندر میشود. ستون مقادیر
* باید سمت چپ بنشیند، یعنی باید فرزند **آخر** باشد و فاصلهٔ ردیف برچسبها هم از چپ
* گرفته شود. جابهجا شدن این ترتیب، اعداد را بیصدا به سمت راست برمیگرداند.
*/
describe('ChartFrame — جای محور مقادیر', () => {
const frameOf = (container: HTMLElement) => {
const root = container.firstElementChild as HTMLElement;
return {
plotRow: root.firstElementChild as HTMLElement,
labelRow: root.lastElementChild as HTMLElement,
};
};
it('ستون مقادیر آخرین فرزند ردیف است تا در RTL سمت چپ بیفتد', () => {
const { container } = render(<TauriLineChart data={real} />);
const { plotRow } = frameOf(container);
const ticks = plotRow.lastElementChild as HTMLElement;
expect(ticks.textContent).toContain('۰');
expect(ticks.className).toContain('flex-col');
// نمودار خودش فرزند اول است، پس سمت راست می‌ماند.
expect((plotRow.firstElementChild as HTMLElement).className).toContain('overflow-hidden');
});
it('ردیف برچسب‌ها به اندازهٔ ستون مقادیر از چپ فاصله می‌گیرد', () => {
const { container } = render(<TauriLineChart data={real} />);
const { labelRow } = frameOf(container);
expect(labelRow.style.paddingLeft).not.toBe('');
expect(labelRow.style.paddingRight).toBe('');
});
});
describe('TauriBarChart', () => {
it('shows the empty state when every value is zero', () => {
render(<TauriBarChart data={zero} />);
@@ -165,3 +199,59 @@ describe('TauriLineChart — ادامهٔ پیش‌بینی', () => {
expect(container.querySelector('.td-forecast')).toBeNull();
});
});
/**
* محور زمان راستبهچپ است: برچسب‌ها در جریان RTL چیده میشوند، پس فروردین سمت راست
* مینشیند. SVG جریان RTL ندارد و اگر اندیس ۰ به `x=0` برود، خط نسبت به برچسبهایش
* آینه میشود دقیقاً باگی که دادهٔ واقعیِ ابتدای سال را روی ماههای پایانی میانداخت.
*/
describe('TauriLineChart — جهت محور زمان', () => {
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 4 ? (i + 1) * 10 : 0,
}));
/** ستونِ هاورِ هر نقطه دقیقاً حول همان نقطه می‌نشیند، پس `left`ش جای نقطه را می‌گوید. */
const hitCenters = (container: HTMLElement): number[] =>
Array.from(container.querySelectorAll<HTMLElement>('.td-hit')).map((el) => {
const left = parseFloat(el.style.left);
const width = parseFloat(el.style.width);
return left + width / 2;
});
it('اولین نقطه سمت راست و آخرین نقطه سمت چپ می‌نشیند', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
expect(centers[0]).toBeCloseTo(100, 5);
expect(centers[centers.length - 1]).toBeCloseTo(0, 5);
});
it('نقطه‌ها به ترتیب زمان از راست به چپ پیش می‌روند', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
for (let i = 1; i < centers.length; i++) {
expect(centers[i]).toBeLessThan(centers[i - 1]);
}
});
/** بخش واقعی سمت راست است و پیش‌بینی سمت چپ، نه برعکس. */
it('بخش واقعی راست‌تر از بخش پیش‌بینی است', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
const lastActual = centers[3];
const firstForecast = centers[4];
expect(lastActual).toBeGreaterThan(firstForecast);
});
/** نقطه باید روی خط بنشیند، نه داخل ستونِ خودش سُر بخورد. */
it('نقطه در مرکز ستون هاورِ خودش است', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
for (const dot of Array.from(container.querySelectorAll<HTMLElement>('.td-dot'))) {
expect(dot.style.left).toBe('50%');
}
});
});
@@ -88,18 +88,11 @@ function ChartFrame({
// column to zero height. Inline `alignItems` is the only reliable override.
return (
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]" style={{ alignItems: 'stretch' }}>
{/* ستونِ مقادیر سمت چپ مینشیند و نمودار سمت راست. جریان صفحه RTL است، پس
«اول در JSX» یعنی «راست روی صفحه» ترتیب عمداً برعکسِ ترتیب دیداری است.
با `direction: ltr` هم میشد، ولی آن به میلههای نمودار ستونی ارث میرسید و
ترتیبشان را نسبت به برچسبها وارونه میکرد. */}
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
{/* y-axis ticks, aligned to gridlines */}
<div
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-left pl-[4px] shrink-0"
style={{ width: yWidth, alignItems: 'flex-start' }}
>
{ticks.map((t, i) => (
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
{faNum.format(t)}
</span>
))}
</div>
{/* plot area */}
{/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
<div className="relative flex-1 min-w-0 overflow-hidden">
@@ -112,9 +105,20 @@ function ChartFrame({
))}
{children}
</div>
{/* y-axis ticks, aligned to gridlines — چسبیده به لبهٔ نمودار، پس راست‌چین */}
<div
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-right pr-[4px] shrink-0"
style={{ width: yWidth, alignItems: 'flex-end' }}
>
{ticks.map((t, i) => (
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
{faNum.format(t)}
</span>
))}
</div>
</div>
{/* x-axis labels */}
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
<div className="flex pt-[8px]" style={{ paddingLeft: yWidth, alignItems: 'flex-start' }}>
{labels.map((l, i) => (
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[var(--text-2)] whitespace-nowrap">
{l}
@@ -189,7 +193,11 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
const W = 100;
const H = 100;
const stepX = data.length > 1 ? W / (data.length - 1) : W;
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
// محور زمان راست‌به‌چپ است — `ChartFrame` برچسب‌ها را در جریان RTL می‌چیند، پس
// فروردین سمت راست می‌نشیند. SVG جریان RTL ندارد، بنابراین اندیس ۰ باید صریحاً به
// لبهٔ راست نگاشت شود؛ وگرنه خط نسبت به برچسب‌هایش آینه می‌شود.
const xOf = (i: number) => W - i * stepX;
const pts = values.map((v, i) => [xOf(i), H - (v / top) * H] as [number, number]);
// خط پیش‌بینی از آخرین نقطهٔ واقعی شروع می‌شود تا وصله‌ی دو بخش دیده نشود.
const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts);
const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : '';
@@ -204,8 +212,9 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
preserveAspectRatio="none"
>
<defs>
{/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */}
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
{/* رنگِ خط در طول زمان از برند به اکسنت میرود (سبک دموی apex). چون زمان
راستبهچپ میرود، گرادیان هم از راست شروع میشود. */}
<linearGradient id="tdIncomeStroke" x1="1" y1="0" x2="0" y2="0">
<stop offset="0%" stopColor="var(--primary)" />
<stop offset="55%" stopColor="var(--primary-600)" />
<stop offset="100%" stopColor="var(--accent)" />
@@ -254,28 +263,38 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
</span>
)}
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */}
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS).
ستونها با `left` فیزیکی و دقیقاً حولِ نقطهٔ خودشان مینشینند، نه با flex:
در flex، هم ترتیبشان به جهت متن گره میخورد و هم `left`ِ نقطه نسبت به ستون
حساب میشد نه نسبت به نمودار یعنی نقطه داخل ستون سُر میخورد. */}
<div className="absolute inset-0">
{data.map((p, i) => {
const isForecast = hasForecast && i >= nActual;
const colW = 100 / data.length;
const xPct = (pts[i][0] / W) * 100;
const yPct = (pts[i][1] / H) * 100;
return (
<div key={i} className="td-hit relative flex-1 min-w-0">
<div
key={i}
className="td-hit absolute top-0 bottom-0"
style={{ left: `${xPct - colW / 2}%`, width: `${colW}%` }}
>
<span
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
style={{
width: 10,
height: 10,
background: isForecast ? 'var(--accent)' : 'var(--primary)',
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
left: '50%',
top: `${yPct}%`,
transform: 'translate(-50%, -50%)',
}}
/>
<span
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
style={{
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
left: '50%',
top: `${yPct}%`,
transform: 'translate(-50%, calc(-100% - 12px))',
background: isForecast ? 'var(--accent)' : 'var(--text)',
color: 'var(--on-primary)',
@@ -5,7 +5,7 @@
* DoctorDashboard feed it their (real-API) data.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const setOverride = { mutate: vi.fn(), isPending: false };
const removeOverride = { mutate: vi.fn(), isPending: false };
let holidays: Array<{ uuid: string; date: number; title: string }> = [];
let overrides: Array<{ uuid: string; date: number; is_working: boolean }> = [];
vi.mock('../../hooks/useResourceCalendar', () => ({
useHolidays: () => ({ holidays, overrides, loading: false, setOverride, removeOverride }),
}));
import NationalHolidaysCard from './NationalHolidaysCard';
const NOWRUZ = { uuid: 'h-1', date: 1774040400, title: 'نوروز' };
describe('NationalHolidaysCard', () => {
beforeEach(() => {
holidays = [NOWRUZ];
overrides = [];
vi.clearAllMocks();
});
it('تعطیلات رسمی سال را نشان می‌دهد', () => {
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText(/نوروز/)).toBeInTheDocument();
expect(screen.getByText('تعطیل')).toBeInTheDocument();
});
/** محیطی که آن روز کار می‌کند، تعطیلیِ سراسری را برای خودش خنثی می‌کند. */
it('با یک کلیک روز را برای این محیط باز می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
await user.click(screen.getByRole('button', { name: 'این روز باز است' }));
expect(setOverride.mutate).toHaveBeenCalledWith({ date: NOWRUZ.date, is_working: true });
});
it('روزی که استثنا خورده، «باز» است و می‌شود دوباره تعطیلش کرد', async () => {
overrides = [{ uuid: 'o-1', date: NOWRUZ.date, is_working: true }];
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText('باز')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'تعطیل کن' }));
expect(removeOverride.mutate).toHaveBeenCalledWith('o-1');
});
/** ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است؛ اینجا فقط استثنا زده می‌شود. */
it('بدون مجوز ویرایش، هیچ دکمه‌ای نمی‌دهد', () => {
renderWithProviders(<NationalHolidaysCard canUpdate={false} year={1405} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('تنظیمات ← تعطیلات رسمی')).toHaveAttribute('href', '/admin/holidays');
});
it('سالِ بدون تعطیلی را صریح می‌گوید', async () => {
holidays = [];
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
await waitFor(() => expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument());
});
});
@@ -0,0 +1,84 @@
import React, { useMemo } from 'react';
import { Link } from 'react-router';
import { useHolidays } from '../../hooks/useResourceCalendar';
import { formatDate, currentJalaliYear } from '../../lib/utils';
/**
* تعطیلات رسمیِ سال، همانجایی که تعطیلی اختصاصی تعریف میشود.
*
* تقویم رسمی یک بار مرکزی ثبت میشود و هر پزشک و هر منبع از آن ارث میبرد؛ اینجا فقط
* دیده میشود و اگر این محیط آن روز باز باشد با یک سوییچ خنثی میشود. ساخت و حذفِ
* خودِ تعطیلی کارِ مدیر سیستم است، نه کلینیک.
*
* یک کامپوننت برای دو مصرف‌کننده: تب تعطیلات پزشک و تب تعطیلات منبع. دو نسخه یعنی دو
* رفتار که با هم واگرا میشوند.
*/
export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYear() }: {
canUpdate: boolean;
year?: number;
}) {
const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year);
const overrideByDate = useMemo(
() => new Map(overrides.map((o) => [o.date, o])),
[overrides],
);
return (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلات رسمی {year.toLocaleString('fa-IR', { useGrouping: false })}</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px', lineHeight: 1.9 }}>
اینها برای همهٔ پزشکان و منابع اعمال میشوند. اگر این محیط روزی را باز است،
همینجا استثنا بزنید مدیریت کاملشان در{' '}
<Link to="/admin/holidays" style={{ color: 'var(--primary)' }}>تنظیمات تعطیلات رسمی</Link>.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : holidays.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
برای سال {year.toLocaleString('fa-IR', { useGrouping: false })} تعطیلی رسمی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{holidays.map((h) => {
const override = overrideByDate.get(h.date);
const open = override?.is_working === true;
return (
<div key={h.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className={`badge ${open ? 'gray' : 'red'}`} style={{ fontSize: 11 }}>
{open ? 'باز' : 'تعطیل'}
</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(h.date)} · {h.title}
</span>
{canUpdate && (
open ? (
<button
type="button"
className="btn secondary sm"
disabled={removeOverride.isPending}
onClick={() => override && removeOverride.mutate(override.uuid)}
>
تعطیل کن
</button>
) : (
<button
type="button"
className="btn secondary sm"
disabled={setOverride.isPending}
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
>
این روز باز است
</button>
)
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Outlet } from 'react-router';
import Sidebar from './Sidebar';
import Topbar from './Topbar';
import { useUiStore, applyBrand } from '../../stores/uiStore';
@@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => navigateMock };
});
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import {
ChevronDownIcon,
UserCircleIcon,
@@ -1,75 +1,48 @@
import React, { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { SearchHeaderP } from '../../pages/subscriptionIcons';
import { useAuthStore } from '../../stores/authStore';
import { usePermissions } from '../../hooks/usePermissions';
import { groupMenu, menuForRole, type SettingsMenuItem } from './settingsMenu';
/**
* Settings sub-navigation for the subscription page item list and order copied
* verbatim from clinic-pro-tauri's `PurchaseSubscription.jsx` navItems, minus
* "مدیریت پزشک" (that lives in the main nav / doctor profile, not here). Shown
* ungated to mirror the tauri source. Each entry maps to a real admin route; the
* "تنظیمات" (security) section has no doctor/clinic route yet disabled.
* سایدبار تنظیمات همان فهرستی که `SettingsMenuPage` روی موبایل نشان میدهد
* (`settingsMenu.ts`)، با دو تفاوتِ عمدی:
*
* This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the
* other settings pages are not affected.
* ۱. «مدیریت پزشک» اینجا نیست؛ جایش منوی اصلی/پروفایل پزشک است.
* ۲. یک آیتم «تنظیمات» غیرفعال ته فهرست است بخش امنیت هنوز مسیر پزشک/کلینیک ندارد.
*
* قبلاً این فهرست کپی جدا بود و هر آیتم تازه باید دو جا اضافه میشد؛ «منابع» و
* «دستهبندیها» فقط در موبایل ظاهر شدند و در سایدبار غایب بودند.
*/
/**
* `roles`: when set, the item is only shown to those roles (omit = every role).
* `perm`: [resource, action] منشی فقط با داشتن این مجوز آیتم را میبیند.
* `alwaysOpen`: برای منشی همیشه نمایش داده میشود (حساب/تنظیمات پایه).
* آیتمهای owner-only (خرید اشتراک، مدیریت منشی) نه `perm` دارند نه `alwaysOpen`
* برای منشی پنهان میشوند.
*/
type NavItem = {
key: string;
label: string;
to?: string;
roles?: string[];
perm?: [string, string];
alwaysOpen?: boolean;
};
const HIDDEN_KEYS = new Set(['doctor']);
const NAV_ITEMS: NavItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet', perm: ['sms', 'view'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', to: '/admin/staff', perm: ['staff', 'view'] },
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings', alwaysOpen: true },
{ key: 'security', label: 'تنظیمات', alwaysOpen: true },
];
const SECURITY_ITEM: SettingsMenuItem = {
key: 'security',
label: 'تنظیمات',
icon: () => null,
group: 'account',
alwaysOpen: true,
disabled: true,
};
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) {
const [query, setQuery] = useState('');
const primaryRole = useAuthStore((s) => s.primaryRole);
const scope = useAuthStore((s) => s.context?.scope);
const { can } = usePermissions();
// منشی، و پزشکِ عضوِ کلینیک (scope=clinic) هر دو محدود-به-مجوزند؛ پزشکِ مستقل و
// مالک آزادند و با فیلترِ نقشیِ معمول کار می‌کنند.
const permissionRestricted = primaryRole === 'secretary' || (primaryRole === 'doctor' && scope === 'clinic');
const items = useMemo(
() => NAV_ITEMS
// آیتمِ بدونِ perm/alwaysOpen برای کاربرِ محدود پنهان است. برای آیتم‌هایی که
// واریانتِ نقشی دارند (مثلِ «نوبت‌دهی» با doctor vs clinic)، با scope تطبیق داده می‌شود.
.filter((i) => {
if (!permissionRestricted) {
return !i.roles || (primaryRole != null && i.roles.includes(primaryRole));
}
if (i.alwaysOpen) return true;
if (!i.perm || !can(i.perm[0], i.perm[1])) return false;
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
return true;
})
.filter((i) => i.label.includes(query.trim())),
// گروه‌ها بعد از فیلترِ جستجو ساخته می‌شوند تا تیترِ دسته‌ای که هیچ نتیجه‌ای ندارد
// بالای فضای خالی نماند.
const groups = useMemo(
() => groupMenu(
[
...menuForRole(primaryRole, can, scope).filter((i) => !HIDDEN_KEYS.has(i.key)),
SECURITY_ITEM,
].filter((i) => i.label.includes(query.trim())),
),
[query, primaryRole, scope, can],
);
const itemCount = groups.reduce((sum, g) => sum + g.items.length, 0);
return (
<aside
@@ -107,36 +80,46 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
</div>
<nav>
{items.map((item) => {
const isActive = item.key === active;
const rowStyle: React.CSSProperties = {
borderRadius: 12, height: 44, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
lineHeight: 1, textAlign: 'right',
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--on-primary)' : 'var(--text-2)',
cursor: item.to ? 'pointer' : 'not-allowed',
transition: 'background .14s',
};
const inner = <span style={{ flex: 1 }}>{item.label}</span>;
if (!item.to) {
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
}
return (
<Link
key={item.key}
to={item.to}
aria-current={isActive ? 'page' : undefined}
style={rowStyle}
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{inner}
</Link>
);
})}
{items.length === 0 && (
{groups.map((group, groupIdx) => (
<section key={group.key} aria-label={group.label}>
<div style={{
fontSize: 12, fontWeight: 700, color: 'var(--text-3)', textAlign: 'right',
padding: '0 14px', marginTop: groupIdx === 0 ? 0 : 14, marginBottom: 6,
}}>
{group.label}
</div>
{group.items.map((item) => {
const isActive = item.key === active;
const rowStyle: React.CSSProperties = {
borderRadius: 12, height: 44, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
lineHeight: 1, textAlign: 'right',
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--on-primary)' : 'var(--text-2)',
cursor: item.to ? 'pointer' : 'not-allowed',
transition: 'background .14s',
};
const inner = <span style={{ flex: 1 }}>{item.label}</span>;
if (!item.to || item.disabled) {
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
}
return (
<Link
key={item.key}
to={item.to}
aria-current={isActive ? 'page' : undefined}
style={rowStyle}
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{inner}
</Link>
);
})}
</section>
))}
{itemCount === 0 && (
<div style={{ padding: '12px 4px', fontSize: 13, color: 'var(--text-3)', textAlign: 'center' }}>
موردی یافت نشد
</div>
@@ -26,14 +26,34 @@ describe('SettingsLayout', () => {
expect(active).toHaveAttribute('href', '/admin/subscription');
});
it('renders the tauri-sourced items as navigable links', () => {
it('renders the shared menu items as navigable links', () => {
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings');
expect(screen.getByText('تگ ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings');
expect(screen.getByText('برچسب‌ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings');
// 'مدیریت پزشک' lives in the main nav, not the settings menu
expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument();
});
/**
* سایدبار و فهرست موبایل یک منبع دارند؛ آیتمی که به منو اضافه میشود باید در هر دو
* دیده شود. قبلاً دو کپی بود و «دستهبندیها» فقط در موبایل ظاهر شد.
*/
it('shows the resource-first entries in the desktop sidebar too', () => {
useAuthStore.setState({ primaryRole: 'clinic' });
renderWithProviders(<SettingsLayout active="service-categories"><div /></SettingsLayout>);
expect(screen.getByText('دسته‌بندی‌ها').closest('a')).toHaveAttribute('href', '/admin/service-categories');
expect(screen.getByText('دسته‌بندی‌ها').closest('a')).toHaveAttribute('aria-current', 'page');
});
/** «منابع» کارِ روزمره است و به سایدبار اصلی رفت؛ نباید در منوی تنظیمات تکرار شود. */
it('does not list منابع in the settings menu any more', () => {
useAuthStore.setState({ primaryRole: 'clinic' });
renderWithProviders(<SettingsLayout active="service-categories"><div /></SettingsLayout>);
expect(screen.queryByText('منابع')).not.toBeInTheDocument();
});
it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => {
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
@@ -1,66 +1,9 @@
import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
// ── Settings menu configuration ─────────────────────────────────────────────
// Source of truth for the *mobile* settings list (SettingsMenuPage). The desktop
// shell renders the shared PurchaseSubscriptionSidebar instead, so there is a
// single settings sidebar across all settings pages (no duplicate menu).
export type SettingsMenuItem = {
key: string;
label: string;
icon: React.ElementType;
to?: string;
/** when set, the item is only shown to these roles (omit = every role) */
roles?: string[];
/** [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. */
perm?: [string, string];
/** برای منشی همیشه نمایش داده می‌شود (حساب کاربری). */
alwaysOpen?: boolean;
};
export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true },
];
/**
* Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر میشود
* (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقشها با roles.
*/
export function menuForRole(
role: string | null | undefined,
can?: (resource: string, action: string) => boolean,
scope?: string | null,
): SettingsMenuItem[] {
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
return SETTINGS_MENU.filter((i) => {
if (permissionRestricted) {
if (i.alwaysOpen) return true;
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
// واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope تطبیق بده.
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
return true;
}
return !i.roles || (role != null && i.roles.includes(role));
});
}
// فهرست منو به `settingsMenu.ts` منتقل شد تا سایدبار دسکتاپ و فهرست موبایل یک منبع
// داشته باشند. این re-export برای مصرف‌کننده‌های موجود می‌ماند.
export { SETTINGS_MENU, menuForRole, type SettingsMenuItem } from './settingsMenu';
/**
* SettingsLayout presentational shell for the doctor/clinic settings area.
@@ -141,6 +141,22 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () =>
expect(screen.queryByText("تنظیمات نوبت‌دهی")).not.toBeInTheDocument();
expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument();
});
/**
* «منابع» از منوی تنظیمات به سایدبار اصلی منتقل شد. گِیتش عوض نشده همان
* `appointment_settings.view` که در منوی تنظیمات داشت.
*/
it("«منابع» در سایدبار اصلی می‌آید و به مجوز appointment_settings گِیت است", () => {
setSecretary({ appointment_settings: { view: true } });
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
expect(screen.getByText("منابع").closest("a")).toHaveAttribute("href", "/admin/resources");
});
it("منشیِ بدون مجوز، «منابع» را نمی‌بیند", () => {
setSecretary({ appointments: { view: true } });
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
expect(screen.queryByText("منابع")).not.toBeInTheDocument();
});
});
describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => {
+147 -12
View File
@@ -9,8 +9,10 @@ import {
ChatBubbleLeftEllipsisIcon,
ChevronDownIcon,
ClipboardDocumentCheckIcon,
ClipboardDocumentListIcon,
Cog6ToothIcon,
CreditCardIcon,
CubeIcon,
CurrencyDollarIcon,
DevicePhoneMobileIcon,
DocumentTextIcon,
@@ -20,6 +22,7 @@ import {
LockClosedIcon,
PlusIcon,
ShieldCheckIcon,
SparklesIcon,
StarIcon,
TagIcon,
UserCircleIcon,
@@ -28,10 +31,10 @@ import {
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { NavLink, useLocation, useNavigate } from "react-router";
import { usePermissions } from "../../hooks/usePermissions";
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore";
@@ -59,7 +62,11 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
*/
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
...APPOINTMENTS_CHILDREN,
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
{
to: "/admin/appointments/reserve",
label: "رزرو نوبت",
icon: ArchiveBoxIcon,
},
];
function buildSections(
@@ -91,19 +98,55 @@ function buildSections(
});
}
if (can("payments", "view")) {
items.push({ to: "/admin/my-payments", icon: CreditCardIcon, label: "پرداخت‌ها" });
items.push({
to: "/admin/my-payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
});
}
if (can("insurances", "view")) {
items.push(
{ to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه", feature: "insurance" },
{ to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", feature: "insurance" },
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
feature: "insurance",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
feature: "insurance",
},
);
}
if (can("services", "view")) {
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
items.push({
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس ها",
});
}
if (can("appointments", "view")) {
items.push({
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "دوره‌های درمان",
});
}
if (can("appointment_settings", "view")) {
items.push({
to: "/admin/resources",
icon: CubeIcon,
label: "منابع",
});
}
if (can("inventory", "view")) {
items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" });
items.push({
to: "/admin/inventory",
icon: ArchiveBoxIcon,
label: "انبارداری",
});
}
// زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
@@ -112,12 +155,24 @@ function buildSections(
return [
{
label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{ label: "مدیریت", items },
{
label: "تنظیمات",
items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }],
items: [
{
to: "/admin/account-settings",
icon: Cog6ToothIcon,
label: "تنظیمات",
},
],
},
];
}
@@ -235,6 +290,16 @@ function buildSections(
icon: CreditCardIcon,
label: "اشتراک‌ها",
},
{
to: "/admin/national-holidays",
icon: CalendarDaysIcon,
label: "تعطیلات رسمی",
},
{
to: "/admin/practice-domains",
icon: SparklesIcon,
label: "حوزه‌های فعالیت",
},
{
to: "/admin/settings",
icon: Cog6ToothIcon,
@@ -301,6 +366,16 @@ function buildSections(
icon: WrenchScrewdriverIcon,
label: "سرویس ها",
},
{
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "دوره‌های درمان",
},
{
to: "/admin/resources",
icon: CubeIcon,
label: "منابع",
},
],
},
{
@@ -376,6 +451,16 @@ function buildSections(
icon: WrenchScrewdriverIcon,
label: "سرویس ها",
},
{
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "دوره‌های درمان",
},
{
to: "/admin/resources",
icon: CubeIcon,
label: "منابع",
},
],
},
{
@@ -443,6 +528,20 @@ function buildSections(
label: "سرویس ها",
});
}
if (can("appointments", "view")) {
items.push({
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "دوره‌های درمان",
});
}
if (can("appointment_settings", "view")) {
items.push({
to: "/admin/resources",
icon: CubeIcon,
label: "منابع",
});
}
if (can("inventory", "view")) {
items.push({
to: "/admin/inventory",
@@ -472,7 +571,13 @@ function buildSections(
return [
{
label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{ label: "مدیریت", items },
{
@@ -490,6 +595,33 @@ function buildSections(
];
}
if (primaryRole === "staff") {
// پرسنل فقط داشبورد خودش و سرویس‌های تخصیص‌یافته را دارد؛ بقیهٔ مسیرها
// سمت API هم برایش بسته است (StaffRouteGuardSubscriber).
return [
{
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{
label: "مدیریت",
items: [
{
to: "/admin/my-sessions",
icon: ClipboardDocumentListIcon,
label: "جلسات امروز من",
},
],
},
];
}
if (primaryRole === "representation") {
return [
{
@@ -570,6 +702,7 @@ const ROLE_LABELS: Record<string, string> = {
clinic: "مالک کلینیک",
doctor: "پزشک",
secretary: "منشی",
staff: "پرسنل",
representation: "نماینده",
user: "کاربر",
};
@@ -724,7 +857,9 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const navigate = useNavigate();
const { can } = usePermissions();
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary");
const { summary: secretaryEarnings } = useSecretaryEarnings(
primaryRole === "secretary",
);
const sections = buildSections(
primaryRole,
dbUuid,
@@ -0,0 +1,53 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../test/utils";
vi.mock("../../hooks/useSubscription", () => ({
useSubscription: () => ({ hasFeature: () => true }),
}));
import { useAuthStore } from "../../stores/authStore";
import Sidebar from "./Sidebar";
/**
* پرسنل فقط داشبورد و سرویسهای خودش را دارد؛ هیچ آیتم مدیریتی نباید در منویش
* ظاهر شود قرینهٔ enforcement سمت API (StaffRouteGuardSubscriber).
*/
describe("Sidebar — نقش پرسنل", () => {
const asStaff = () =>
useAuthStore.setState({
primaryRole: "staff",
dbUuid: "d1",
userName: "زهرا احمدی",
availableContexts: [],
context: {
type: "doctor",
db_uuid: "d1",
name: "مطب تست",
role: "staff",
scope: "doctor",
permissions: { version: 1, resources: { services: { view: true }, appointments: { view: true } } },
},
} as any);
it("shows only داشبورد and جلسات امروز من", () => {
asStaff();
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
expect(screen.getByText("داشبورد").closest("a")).toHaveAttribute("href", "/admin/dashboard");
expect(screen.getByText("جلسات امروز من").closest("a")).toHaveAttribute("href", "/admin/my-sessions");
expect(screen.getByText("پرسنل")).toBeInTheDocument(); // برچسب نقش در فوتر
// صفحهٔ «سرویس‌های من» حذف شد؛ فهرست سرویس‌ها روی خودِ داشبورد است.
expect(screen.queryByText("سرویس‌های من")).not.toBeInTheDocument();
});
it("hides management entries", () => {
asStaff();
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
for (const label of ["پرونده بیماران", "تنظیمات", "نوبت‌ها", "پرداخت‌ها"]) {
expect(screen.queryByText(label)).not.toBeInTheDocument();
}
});
});
@@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => navigateMock };
});
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import {
BellIcon, SunIcon, MoonIcon, Bars3Icon, Cog6ToothIcon,
MagnifyingGlassIcon, XMarkIcon, CheckIcon,
@@ -38,7 +38,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
{/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */}
<div className="topbar-search">
<MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
<input placeholder="جستجو" readOnly />
<input placeholder="جستجو" aria-label="جستجو" readOnly />
</div>
<div style={{ flex: 1 }} />
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import {
SETTINGS_GROUPS, SETTINGS_MENU, groupMenu, groupedMenuForRole, menuForRole,
} from './settingsMenu';
/**
* منو یک فهرست تختِ ۱۶ ردیفی بود که ترتیبش از تاریخِ اضافهشدنِ آیتمها میآمد:
* «خرید اشتراک» کممصرفترین ردیف اول بود و «مدیریت پرداخت» وسط آیتمهای
* نوبتدهی. این تستها همان چیدمان تازه را قفل میکنند.
*/
describe('گروه‌بندی منوی تنظیمات', () => {
it('هر آیتم منو دقیقاً به یکی از گروه‌های تعریف‌شده تعلق دارد', () => {
const known = new Set(SETTINGS_GROUPS.map((g) => g.key));
for (const item of SETTINGS_MENU) {
expect(known.has(item.group), `${item.key} گروه نامعتبر دارد`).toBe(true);
}
});
it('گروه‌ها به ترتیب تعریف‌شده برمی‌گردند', () => {
const keys = groupedMenuForRole('clinic').map((g) => g.key);
expect(keys).toEqual(['practice', 'scheduling', 'patients', 'finance', 'account']);
});
it('خرید اشتراک در گروه آخر است، نه ردیف اول', () => {
const groups = groupedMenuForRole('doctor');
const last = groups[groups.length - 1];
expect(last.key).toBe('account');
expect(last.items.map((i) => i.key)).toEqual(['subscription', 'account']);
expect(menuForRole('doctor')[0].key).not.toBe('subscription');
});
it('مدیریت پرداخت کنار بیمه و تخفیف است، نه وسط نوبت‌دهی', () => {
const finance = groupedMenuForRole('doctor').find((g) => g.key === 'finance');
expect(finance?.items.map((i) => i.key)).toEqual(['payment', 'insurance', 'discounts', 'sms']);
});
it('گروه خالی برنمی‌گردد', () => {
// منشیِ فقط-با-مجوزِ پرداخت: نباید تیتر «نوبت‌دهی» بالای فضای خالی ببیند.
const can = (resource: string) => resource === 'payments';
const groups = groupedMenuForRole('secretary', can);
expect(groups.map((g) => g.key)).toEqual(['finance', 'account']);
for (const group of groups) {
expect(group.items.length).toBeGreaterThan(0);
}
});
it('فیلترِ جستجو گروه بی‌نتیجه را حذف می‌کند', () => {
const filtered = groupMenu(menuForRole('doctor').filter((i) => i.label.includes('بیمه')));
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('finance');
expect(filtered[0].items.map((i) => i.key)).toEqual(['insurance']);
});
it('گروه‌بندی هیچ آیتمی را نمی‌اندازد و تکراری نمی‌سازد', () => {
for (const role of ['doctor', 'clinic']) {
const flat = menuForRole(role);
const grouped = groupedMenuForRole(role).flatMap((g) => g.items);
expect(grouped).toHaveLength(flat.length);
expect(new Set(grouped.map((i) => i.to)).size).toBe(flat.length);
}
});
it('گِیت نقشی بعد از گروه‌بندی هم برقرار است', () => {
const practice = groupedMenuForRole('doctor').find((g) => g.key === 'practice');
expect(practice?.items.map((i) => i.key)).not.toContain('clinic-doctors');
expect(practice?.items.map((i) => i.key)).toContain('doctor');
});
});
@@ -0,0 +1,134 @@
import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
RectangleStackIcon, HashtagIcon, SparklesIcon,
} from '@heroicons/react/24/outline';
/**
* تنها منبعِ حقیقتِ منوی تنظیمات هم فهرست موبایل (`SettingsMenuPage`) و هم سایدبار
* دسکتاپ (`PurchaseSubscriptionSidebar`) از همین میخوانند.
*
* قبلاً هرکدام فهرست خودش را داشت و آیتم تازه فقط در یکی ظاهر میشد؛ «منابع» و
* «دستهبندیها» در موبایل بودند و در سایدبار نبودند.
*/
/**
* دستههای منو، به ترتیبِ نمایش.
*
* ترتیب از «چقدر به کارِ روزمره نزدیک است» میآید، نه از تاریخِ اضافهشدنِ آیتم:
* اول ساختارِ مطب، بعد نوبتدهی، بعد بیماران، بعد مالی، و ته فهرست حساب و اشتراک
* که ماهی یکبار سراغش میروند. پیش از این فهرست تخت بود و «خرید اشتراک» کممصرفترین
* آیتم اولین ردیف بود، در حالی که «مدیریت پرداخت» وسط آیتمهای نوبتدهی افتاده بود.
*/
export const SETTINGS_GROUPS = [
{ key: 'practice', label: 'مطب و کلینیک' },
{ key: 'scheduling', label: 'نوبت‌دهی' },
{ key: 'patients', label: 'بیماران' },
{ key: 'finance', label: 'مالی' },
{ key: 'account', label: 'حساب و اشتراک' },
] as const;
export type SettingsGroupKey = (typeof SETTINGS_GROUPS)[number]['key'];
export type SettingsMenuGroup = {
key: SettingsGroupKey;
label: string;
items: SettingsMenuItem[];
};
export type SettingsMenuItem = {
key: string;
label: string;
icon: React.ElementType;
group: SettingsGroupKey;
to?: string;
/** when set, the item is only shown to these roles (omit = every role) */
roles?: string[];
/** [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. */
perm?: [string, string];
/** برای منشی همیشه نمایش داده می‌شود (حساب کاربری). */
alwaysOpen?: boolean;
/** مقصدی ندارد — در سایدبار خاکستری و غیرقابل کلیک نشان داده می‌شود. */
disabled?: boolean;
};
// ترتیب همین آرایه ترتیبِ نمایش است؛ آیتم‌های هر دسته پشت سر هم می‌آیند.
export const SETTINGS_MENU: SettingsMenuItem[] = [
// ── مطب و کلینیک — چه کسی اینجا کار می‌کند و چه چیزی ارائه می‌شود ───────────
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, group: 'practice', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
// مجوزش `clinic_info` است چون ذخیره‌اش روی PATCH /api/v1/clinic/{uuid} می‌نشیند.
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['clinic_info', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, group: 'practice', to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] },
// ── نوبت‌دهی — قواعدی که تقویم را می‌سازند ────────────────────────────────
// «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات.
// گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا می‌دیدندش.
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, group: 'scheduling', to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
// ── بیماران — قواعدی که روی پروندهٔ بیمار می‌نشینند ────────────────────────
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, group: 'patients', to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
{ key: 'tags', label: 'برچسب‌ها', icon: TagIcon, group: 'patients', to: '/admin/tags-settings', perm: ['tags', 'view'] },
// ── مالی — هر چیزی که به پول یا اعتبار وصل است ────────────────────────────
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, group: 'finance', to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, group: 'finance', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, group: 'finance', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, group: 'finance', to: '/admin/sms-wallet', perm: ['sms', 'view'] },
// ── حساب و اشتراک — کم‌مصرف‌ترین‌ها، ته فهرست ─────────────────────────────
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, group: 'account', to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, group: 'account', to: '/admin/account-settings', alwaysOpen: true },
];
/**
* Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر میشود
* (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقشها با roles.
*/
export function menuForRole(
role: string | null | undefined,
can?: (resource: string, action: string) => boolean,
scope?: string | null,
): SettingsMenuItem[] {
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
return SETTINGS_MENU.filter((i) => {
if (permissionRestricted) {
if (i.alwaysOpen) return true;
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
// واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope تطبیق بده.
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
return true;
}
return !i.roles || (role != null && i.roles.includes(role));
});
}
/**
* همان خروجی `menuForRole`، دستهبندیشده و به ترتیبِ `SETTINGS_GROUPS`.
*
* دستهٔ خالی برنمی‌گردد: منشی‌ای که مجوز مالی ندارد نباید تیترِ «مالی» را ببیند و
* زیرش هیچ. هر دو مصرفکنندهٔ منو از همین میخوانند تا ترتیب دسکتاپ و موبایل واگرا نشود.
*/
export function groupMenu(items: SettingsMenuItem[]): SettingsMenuGroup[] {
return SETTINGS_GROUPS
.map((group) => ({
key: group.key,
label: group.label,
items: items.filter((i) => i.group === group.key),
}))
.filter((group) => group.items.length > 0);
}
export function groupedMenuForRole(
role: string | null | undefined,
can?: (resource: string, action: string) => boolean,
scope?: string | null,
): SettingsMenuGroup[] {
return groupMenu(menuForRole(role, can, scope));
}
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import { toast } from 'sonner';
import PatientTreatmentTab from './PatientTreatmentTab';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const CASE_UUID = 'case-1';
const summary = (over: Record<string, unknown> = {}) => ({
uuid: CASE_UUID,
status: 'active',
total_sessions: 5,
completed_sessions: 3,
service: { uuid: 'svc-1', name: 'لیزر ناحیه ۱' },
...over,
});
/** فهرست دوره‌ها، تقویم دوره، و بقیهٔ GETها. */
function mockApi(caseOver: Record<string, unknown> = {}) {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/treatment-cases')) {
return Promise.resolve({ success: true, data: [summary(caseOver)] });
}
if (url.includes('/plan')) {
return Promise.resolve({ success: true, data: {
case: { ...summary(caseOver), patient_national_code: null },
resource: null,
sessions: [],
} });
}
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => {
get.mockReset();
patch.mockReset();
patch.mockResolvedValue({ success: true, data: {} });
mockApi();
});
/** دورهٔ انتخاب‌شده در URL می‌نشیند؛ تست هم از همان مسیر شروع می‌کند. */
const render = () => renderWithProviders(
<PatientTreatmentTab recordUuid="rec-1" />,
{ route: `/admin/patients/p-1?case=${CASE_UUID}` },
);
const plusBtn = () => screen.getByRole('button', { name: 'افزودن یک جلسه' });
const minusBtn = () => screen.getByRole('button', { name: 'کم کردن یک جلسه' });
/**
* تعداد جلسات سرِ پروندهٔ بیمار تصمیم گرفته میشود، ولی تا این تغییر فقط از صفحهٔ
* «دورههای درمان» و پشت یک مودال قابل تغییر بود.
*/
describe('PatientTreatmentTab — تعداد جلسات', () => {
it('یک جلسه اضافه می‌کند', async () => {
render();
fireEvent.click(await screen.findByRole('button', { name: 'افزودن یک جلسه' }));
await waitFor(() => expect(patch).toHaveBeenCalledWith(
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 6 },
));
});
it('یک جلسه کم می‌کند', async () => {
render();
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
await waitFor(() => expect(patch).toHaveBeenCalledWith(
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 4 },
));
});
it('وقتی همهٔ جلسه‌ها انجام شده، کم کردن قفل است', async () => {
mockApi({ total_sessions: 4, completed_sessions: 4 });
render();
await waitFor(() => expect(minusBtn()).toBeDisabled());
expect(screen.getByText(/همهٔ جلسه‌ها انجام شده/)).toBeInTheDocument();
// افزودن همچنان آزاد است.
expect(plusBtn()).not.toBeDisabled();
});
it('کف دو جلسه رعایت می‌شود', async () => {
mockApi({ total_sessions: 2, completed_sessions: 0 });
render();
await waitFor(() => expect(minusBtn()).toBeDisabled());
expect(screen.getByText(/کمتر از ۲ جلسه ممکن نیست/)).toBeInTheDocument();
});
it('سقف شصت جلسه رعایت می‌شود', async () => {
mockApi({ total_sessions: 60, completed_sessions: 0 });
render();
await waitFor(() => expect(plusBtn()).toBeDisabled());
expect(minusBtn()).not.toBeDisabled();
});
it('خطای سرور را همان‌طور که آمده نشان می‌دهد', async () => {
// سرور دقیق‌تر از هر متن ثابتی می‌گوید چند جلسه قفل است.
patch.mockRejectedValue(new Error('۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست'));
render();
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
'۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست',
));
});
it('پیشرفت دوره با نسبت واقعی اعلام می‌شود', async () => {
render();
const bar = await screen.findByRole('progressbar', { name: /پیشرفت دوره/ });
expect(bar).toHaveAttribute('aria-valuenow', '3');
expect(bar).toHaveAttribute('aria-valuemax', '5');
});
it('دکمهٔ ویرایش دوره برای بقیهٔ فیلدها هست', async () => {
render();
expect(await screen.findByRole('button', { name: /ویرایش دوره/ })).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More