diff --git a/client/CLAUDE.md b/client/CLAUDE.md
index 725416c..cd4e89f 100644
--- a/client/CLAUDE.md
+++ b/client/CLAUDE.md
@@ -123,16 +123,17 @@ client/
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
-│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + ServicePriceRow list + latest review; "درخواست رزرو" hands off to /bookings/request (f7)
+│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
- │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions: Cancel CTA while cancellable, refund section once cancelled — reuses the cached booking query)
+ │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~7–10-day ETA, failed=contact-support; polls only while non-terminal
+ │ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
@@ -146,7 +147,8 @@ client/
│ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff
│ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card
- │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
+ │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13)
+ │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later)
│ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
@@ -165,7 +167,7 @@ client/
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
- │ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
+ │ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
│ │ │ └── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
@@ -185,7 +187,7 @@ client/
│ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels)
│ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self)
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
- │ ├── PatientCard/ # E1 patient summary card + edit/archive actions
+ │ ├── PatientCard/ # E1 patient summary card (composes the shared PatientHeader) + edit/archive actions + optional onOpen tap-to-open (→ f13 E2 record)
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
@@ -207,6 +209,10 @@ client/
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross − commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
+ │ ├── RatingInput/ # f13 1–5 star input/display (custom, on AppIcon "star"; filled=var(--bal-warning), empty=var(--bal-divider)); interactive=radiogroup of radios, readOnly=static role="img"; used by the review form + review/my-review display (tested)
+ │ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested)
+ │ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested)
+ │ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
@@ -265,6 +271,8 @@ client/
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=gross−clawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
+ │ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
+ │ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -364,10 +372,12 @@ async function MyServerComponent() {
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*` — `bnpl_revert` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'bnpl'` — the f11 BNPL installment checkout (D1–D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow`
+- `'reviews'` — the f13 leave-a-review flow + the C3 reviews tab: the form labels (`title`/`rating_label`/`body_label`/`tags_label`/`submit`), the review-tag labels keyed off the code (`tag_{punctual,professional,clean,kind,communicative}` — never off the wire), the not-eligible reasons (`reason_*`), the moderation-status labels (`status_pending_moderation`/`status_published`/`status_hidden`/`status_rejected`), the persistent "under review" + my-review copy, the booking-detail CTA (`cta_leave`/`cta_under_review`/`cta_view_review`), the aggregate count (`count` ICU plural), the masked author fallback (`author_masked`), and the list empty/error/load-more; consumed by the review page, the C3 `ReviewsPanel`, and the `LeaveReviewCta`
+- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
-files): `reviews`, `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
+files): `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
diff --git a/client/messages/en.json b/client/messages/en.json
index 18ef60f..ea778ae 100644
--- a/client/messages/en.json
+++ b/client/messages/en.json
@@ -107,7 +107,8 @@
"archived": "Patient archived",
"age_years": "{age} yrs",
"conditions_none": "No recorded conditions",
- "unavailable": "This patient isn't available to you."
+ "unavailable": "This patient isn't available to you.",
+ "open_record": "Open {name}'s record"
},
"profile": {
"title": "Profile",
@@ -360,11 +361,10 @@
"specialty_pediatric": "Pediatric care",
"specialty_post_surgery": "Post-surgery care",
"specialty_wound_care": "Wound care",
- "services_title": "Services & prices",
"services_empty": "No services listed yet.",
- "latest_review_title": "Latest review",
- "no_reviews": "No reviews yet.",
- "request_booking": "Request booking"
+ "request_booking": "Request booking",
+ "tab_services": "Services",
+ "tab_reviews": "Reviews"
},
"booking": {
"gender_female": "Woman",
@@ -476,13 +476,11 @@
"status_expired_no_response": "No response in time",
"status_payment_deadline_expired": "Payment window lapsed",
"status_cancelled_by_customer": "Cancelled",
-
"bd_title": "Booking",
"bd_ref": "Booking #{id}",
"bd_not_found_title": "Booking not found",
"bd_not_found_body": "This booking doesn't exist or isn't yours.",
"bd_my_bookings": "My bookings",
-
"timeline_title": "Progress",
"bstatus_pending_payment": "Pending payment",
"bstatus_confirmed": "Confirmed",
@@ -491,14 +489,12 @@
"bstatus_disputed": "Under review",
"bstatus_closed": "Closed",
"bstatus_cancelled": "Cancelled",
-
"confirmed_note": "Your booking is confirmed. The visits below are scheduled.",
"in_progress_note": "A visit is currently underway.",
"completed_note": "All visits are complete.",
"dispute_window_note": "Payment is released to the nurse after the review window closes on {date}.",
"cancelled_note": "This booking was cancelled.",
"disputed_note": "This booking is under review by our support team.",
-
"sessions_title": "Visit schedule",
"session_index": "Visit {n}",
"session_count": "{count, plural, =0 {no visits} one {# visit} other {# visits}}",
@@ -509,13 +505,11 @@
"sstatus_cancelled": "Cancelled",
"session_elapsed": "On site {duration}",
"session_payout": "Visit payout",
-
"money_title": "Payment summary",
"money_gross": "Service cost",
"money_commission": "Balinyaar fee",
"money_payout": "Nurse payout",
"money_nurse_earning": "Your earning",
-
"evv_today_title": "Today's visit",
"evv_visits_title": "Today's visits",
"evv_visits_subtitle": "Clock in and out of each visit with EVV.",
@@ -538,7 +532,6 @@
"evv_check_out_error": "Couldn't record the check-out. Please try again.",
"evv_nurse_view": "Nurse view",
"view_booking": "View booking",
-
"care_title": "Care instructions",
"care_subtitle": "Shared with you as the assigned nurse after confirmation.",
"care_conditions": "Conditions",
@@ -551,7 +544,6 @@
"care_error": "Couldn't load the care instructions.",
"care_locked_title": "Care details",
"care_locked_body": "The full care record is visible only to your assigned nurse and support.",
-
"list_title": "My bookings",
"list_subtitle": "Your confirmed engagements.",
"list_empty_title": "No bookings yet",
@@ -1013,5 +1005,87 @@
"amount_transferred_label": "Transferred",
"detail_bookings_title": "Bookings covered",
"detail_bookings_hint": "This transfer paid for these visits."
+ },
+ "reviews": {
+ "title": "Leave a review",
+ "subtitle": "Share your experience to help other families.",
+ "for_nurse": "About {name}",
+ "rating_label": "Your rating",
+ "body_label": "Comments (optional)",
+ "body_placeholder": "How was the care? Anything worth sharing…",
+ "tags_label": "What stood out",
+ "submit": "Submit review",
+ "submitting": "Submitting…",
+ "my_review_title": "Your review",
+ "under_review_body": "Your review has been submitted and will be published after moderation.",
+ "status_pending_moderation": "Under review",
+ "status_published": "Published",
+ "status_hidden": "Hidden",
+ "status_rejected": "Rejected",
+ "not_eligible_title": "Review not available",
+ "reason_not_completed": "You can only review a completed visit.",
+ "reason_already_reviewed": "You have already reviewed this booking.",
+ "reason_not_owner": "You don't have access to this booking.",
+ "reason_not_found": "Booking not found.",
+ "error_submit": "Couldn't submit your review. Please try again.",
+ "tag_punctual": "Punctual",
+ "tag_professional": "Professional",
+ "tag_clean": "Clean",
+ "tag_kind": "Kind",
+ "tag_communicative": "Good communication",
+ "cta_leave": "Leave a review",
+ "cta_under_review": "Your review is under review",
+ "cta_view_review": "View your review",
+ "count": "{count, plural, =0 {No reviews} one {# review} other {# reviews}}",
+ "author_masked": "Balinyaar user",
+ "reviews_empty": "No reviews yet.",
+ "load_error": "Couldn't load reviews.",
+ "retry": "Retry",
+ "load_more": "Show more",
+ "loading": "Loading…"
+ },
+ "records": {
+ "ownership_banner": "This record belongs to the family and is maintained by them.",
+ "tab_medications": "Medications",
+ "tab_routine": "Routine",
+ "tab_history": "History",
+ "tab_tasks": "Tasks",
+ "access_denied_title": "No access",
+ "access_denied_body": "You don't have access to this record.",
+ "not_found_title": "Patient not found",
+ "not_found_body": "This patient could not be found.",
+ "back_to_patients": "Back to patients",
+ "load_error": "Couldn't load the record.",
+ "saved": "Record updated",
+ "save_error": "Couldn't save your changes. Please try again.",
+ "edit": "Edit",
+ "add_item": "Add",
+ "remove": "Remove",
+ "medications_empty": "No medications recorded.",
+ "routine_empty": "No routine recorded.",
+ "history_empty": "No notes recorded.",
+ "tasks_empty": "No tasks recorded.",
+ "med_name": "Medication",
+ "med_dosage": "Dosage",
+ "med_frequency": "Frequency",
+ "med_timing": "Timing / notes",
+ "routine_label": "Routine",
+ "routine_time": "Time of day",
+ "routine_note": "Note",
+ "task_label": "Task",
+ "task_done": "Done",
+ "author_fallback": "Nurse",
+ "prev": "Previous",
+ "next": "Next",
+ "page_of": "{page} of {total}",
+ "notes_title": "Visit note",
+ "tasks_checklist_title": "Today's tasks",
+ "note_label": "Note",
+ "note_placeholder": "Describe today's visit…",
+ "note_submit": "Save note",
+ "note_saved": "Note saved",
+ "note_error": "Couldn't save the note. Please try again.",
+ "note_required": "Please write a note.",
+ "continuity_title": "Patient history"
}
}
diff --git a/client/messages/fa.json b/client/messages/fa.json
index fa5dcd1..8e13d28 100644
--- a/client/messages/fa.json
+++ b/client/messages/fa.json
@@ -107,7 +107,8 @@
"archived": "بیمار آرشیو شد",
"age_years": "{age} سال",
"conditions_none": "وضعیت خاصی ثبت نشده",
- "unavailable": "این بیمار در دسترس شما نیست."
+ "unavailable": "این بیمار در دسترس شما نیست.",
+ "open_record": "مشاهدهٔ پروندهٔ {name}"
},
"profile": {
"title": "پروفایل",
@@ -360,11 +361,10 @@
"specialty_pediatric": "مراقبت از کودک",
"specialty_post_surgery": "مراقبت پس از جراحی",
"specialty_wound_care": "مراقبت زخم",
- "services_title": "خدمات و قیمتها",
"services_empty": "هنوز خدمتی ثبت نشده است.",
- "latest_review_title": "آخرین نظر",
- "no_reviews": "هنوز نظری ثبت نشده است.",
- "request_booking": "درخواست رزرو"
+ "request_booking": "درخواست رزرو",
+ "tab_services": "خدمات",
+ "tab_reviews": "نظرات"
},
"booking": {
"gender_female": "خانم",
@@ -476,13 +476,11 @@
"status_expired_no_response": "بدون پاسخ در مهلت",
"status_payment_deadline_expired": "پایان مهلت پرداخت",
"status_cancelled_by_customer": "لغوشده",
-
"bd_title": "رزرو",
"bd_ref": "رزرو #{id}",
"bd_not_found_title": "رزرو یافت نشد",
"bd_not_found_body": "این رزرو وجود ندارد یا متعلق به شما نیست.",
"bd_my_bookings": "رزروهای من",
-
"timeline_title": "روند رزرو",
"bstatus_pending_payment": "در انتظار پرداخت",
"bstatus_confirmed": "تاییدشده",
@@ -491,14 +489,12 @@
"bstatus_disputed": "در حال بررسی اختلاف",
"bstatus_closed": "بستهشده",
"bstatus_cancelled": "لغوشده",
-
"confirmed_note": "رزرو شما تایید شد. ویزیتهای زیر برنامهریزی شدهاند.",
"in_progress_note": "یک ویزیت هماکنون در حال انجام است.",
"completed_note": "همهٔ ویزیتها به پایان رسید.",
"dispute_window_note": "مبلغ پس از پایان مهلت بررسی در {date} برای پرستار آزاد میشود.",
"cancelled_note": "این رزرو لغو شده است.",
"disputed_note": "این رزرو در حال بررسی توسط تیم پشتیبانی است.",
-
"sessions_title": "برنامهٔ ویزیتها",
"session_index": "ویزیت {n}",
"session_count": "{count} ویزیت",
@@ -509,13 +505,11 @@
"sstatus_cancelled": "لغوشده",
"session_elapsed": "مدت حضور {duration}",
"session_payout": "سهم این ویزیت",
-
"money_title": "خلاصهٔ پرداخت",
"money_gross": "هزینهٔ خدمت",
"money_commission": "کارمزد پلتفرم",
"money_payout": "سهم پرستار",
"money_nurse_earning": "درآمد شما",
-
"evv_today_title": "ویزیت امروز",
"evv_visits_title": "ویزیتهای امروز",
"evv_visits_subtitle": "برای هر ویزیت، ورود و خروج را با EVV ثبت کنید.",
@@ -538,7 +532,6 @@
"evv_check_out_error": "ثبت خروج انجام نشد. دوباره تلاش کنید.",
"evv_nurse_view": "نمای پرستار",
"view_booking": "مشاهدهٔ رزرو",
-
"care_title": "دستورالعمل مراقبت",
"care_subtitle": "پس از تایید، بهعنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
"care_conditions": "شرایط پزشکی",
@@ -551,7 +544,6 @@
"care_error": "بارگذاری دستورالعمل مراقبت ممکن نشد.",
"care_locked_title": "شرح مراقبت",
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
-
"list_title": "رزروهای من",
"list_subtitle": "مراقبتهای تاییدشدهٔ شما.",
"list_empty_title": "هنوز رزروی ندارید",
@@ -1013,5 +1005,87 @@
"amount_transferred_label": "واریزشده",
"detail_bookings_title": "رزروهای دربرگرفته",
"detail_bookings_hint": "این واریز بابت این ویزیتها پرداخت شده است."
+ },
+ "reviews": {
+ "title": "ثبت نظر",
+ "subtitle": "تجربهتان را با دیگر خانوادهها به اشتراک بگذارید.",
+ "for_nurse": "دربارهٔ {name}",
+ "rating_label": "امتیاز شما",
+ "body_label": "توضیحات (اختیاری)",
+ "body_placeholder": "مراقبت چطور بود؟ اگر نکتهای هست بنویسید…",
+ "tags_label": "ویژگیهای برجسته",
+ "submit": "ثبت نظر",
+ "submitting": "در حال ثبت…",
+ "my_review_title": "نظر شما",
+ "under_review_body": "نظر شما ثبت شد و پس از بررسی منتشر میشود.",
+ "status_pending_moderation": "در حال بررسی",
+ "status_published": "منتشر شد",
+ "status_hidden": "پنهان شد",
+ "status_rejected": "رد شد",
+ "not_eligible_title": "امکان ثبت نظر نیست",
+ "reason_not_completed": "نظر فقط برای ویزیتهای تکمیلشده امکانپذیر است.",
+ "reason_already_reviewed": "شما قبلاً برای این رزرو نظر ثبت کردهاید.",
+ "reason_not_owner": "شما به این رزرو دسترسی ندارید.",
+ "reason_not_found": "رزرو پیدا نشد.",
+ "error_submit": "ثبت نظر ممکن نشد. دوباره تلاش کنید.",
+ "tag_punctual": "وقتشناس",
+ "tag_professional": "حرفهای",
+ "tag_clean": "رعایت بهداشت",
+ "tag_kind": "مهربان",
+ "tag_communicative": "ارتباط خوب",
+ "cta_leave": "ثبت نظر",
+ "cta_under_review": "نظر شما در حال بررسی است",
+ "cta_view_review": "مشاهدهٔ نظر شما",
+ "count": "{count, plural, =0 {بدون نظر} other {# نظر}}",
+ "author_masked": "کاربر بالینیار",
+ "reviews_empty": "هنوز نظری ثبت نشده است.",
+ "load_error": "بارگذاری نظرها ممکن نشد.",
+ "retry": "تلاش دوباره",
+ "load_more": "نمایش بیشتر",
+ "loading": "در حال بارگذاری…"
+ },
+ "records": {
+ "ownership_banner": "این پرونده متعلق به خانواده است و توسط خانواده نگهداری میشود.",
+ "tab_medications": "داروها",
+ "tab_routine": "روتین",
+ "tab_history": "سوابق",
+ "tab_tasks": "وظایف",
+ "access_denied_title": "دسترسی ندارید",
+ "access_denied_body": "شما به این پرونده دسترسی ندارید.",
+ "not_found_title": "بیمار پیدا نشد",
+ "not_found_body": "این بیمار یافت نشد.",
+ "back_to_patients": "بازگشت به بیماران",
+ "load_error": "بارگذاری پرونده ممکن نشد.",
+ "saved": "پرونده بهروزرسانی شد",
+ "save_error": "ذخیرهٔ تغییرات ممکن نشد. دوباره تلاش کنید.",
+ "edit": "ویرایش",
+ "add_item": "افزودن",
+ "remove": "حذف",
+ "medications_empty": "دارویی ثبت نشده است.",
+ "routine_empty": "روتینی ثبت نشده است.",
+ "history_empty": "یادداشتی ثبت نشده است.",
+ "tasks_empty": "وظیفهای ثبت نشده است.",
+ "med_name": "دارو",
+ "med_dosage": "دوز",
+ "med_frequency": "دفعات مصرف",
+ "med_timing": "زمان / توضیحات",
+ "routine_label": "روتین",
+ "routine_time": "زمان روز",
+ "routine_note": "توضیح",
+ "task_label": "وظیفه",
+ "task_done": "انجام شد",
+ "author_fallback": "پرستار",
+ "prev": "قبلی",
+ "next": "بعدی",
+ "page_of": "{page} از {total}",
+ "notes_title": "یادداشت ویزیت",
+ "tasks_checklist_title": "کارهای امروز",
+ "note_label": "یادداشت",
+ "note_placeholder": "شرح ویزیت امروز…",
+ "note_submit": "ثبت یادداشت",
+ "note_saved": "یادداشت ثبت شد",
+ "note_error": "ثبت یادداشت ممکن نشد. دوباره تلاش کنید.",
+ "note_required": "متن یادداشت را وارد کنید.",
+ "continuity_title": "سوابق بیمار"
}
}
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx
index e9ac0d3..52f043d 100644
--- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx
@@ -5,10 +5,11 @@ import { Stack, Typography } from '@mui/material';
import { BookingDetailView } from '@/components/booking';
import RefundStatusCard from '@/components/RefundStatusCard';
import AppButton from '@/components/common/AppButton';
-import { bookingCancelPath, bookingRefundStatusPath } from '@/constants';
+import { bookingCancelPath, bookingRefundStatusPath, bookingReviewPath } from '@/constants';
import { useBookingDetail } from '@/services/bookings';
import { useRefundStatus } from '@/services/refunds';
import { isBookingCancellable } from '@/services/refunds/types';
+import { useMyReviewForBooking } from '@/services/reviews';
/**
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
@@ -28,6 +29,42 @@ export default function CustomerBookingDetailPage() {
{bookingId > 0 && }
+ {bookingId > 0 && }
+
+ );
+}
+
+/**
+ * The f13 leave-a-review entry — reads the already-cached booking detail (no extra fetch) and only offers the
+ * CTA once the booking is completed/closed. If the customer has already reviewed it, the CTA becomes a passive
+ * "under review" affordance (the review is `pending_moderation` and never shown publicly here). The my-review
+ * read is enabled only for a review-eligible booking, so an active booking triggers no reviews query.
+ */
+function LeaveReviewCta({ bookingId }: { bookingId: number }) {
+ const router = useRouter();
+ const locale = useLocale();
+ const t = useTranslations('reviews');
+
+ const { data: booking } = useBookingDetail(bookingId, 'customer');
+ const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
+ const { data: myReview } = useMyReviewForBooking(bookingId, { enabled: reviewable });
+
+ if (!booking || !reviewable) return null;
+
+ const alreadyReviewed = Boolean(myReview && myReview.status !== 'none');
+ const underReview = myReview?.status === 'pending_moderation';
+
+ return (
+
+ router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
+ sx={{ m: 0 }}
+ >
+ {alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
+
);
}
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx
new file mode 100644
index 0000000..7ea0d34
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx
@@ -0,0 +1,201 @@
+'use client';
+import { useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { useParams, useRouter } from 'next/navigation';
+import { useSnackbar } from 'notistack';
+import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
+import { AppButton, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
+import type { StatusKind } from '@/components';
+import { formatShamsiDate } from '@/utils';
+import { useBookingDetail } from '@/services/bookings';
+import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
+import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
+
+const REVIEW_BODY_MAX = 2000;
+
+/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
+const STATUS_KIND: Record = {
+ pending_moderation: 'pending',
+ published: 'verified',
+ hidden: 'neutral',
+ rejected: 'rejected',
+};
+
+/**
+ * f13 — Leave a review («ثبت نظر»): the customer's one moderated review for a completed booking. The form is
+ * shown only when the server says the booking can be reviewed AND it is not already reviewed; on submit it
+ * flips to the persistent "under review" state (the review is `pending_moderation` and never appears publicly
+ * here). One review per booking (1:1) — a returning customer sees their review's state, never a second form.
+ */
+export default function LeaveReviewPage() {
+ const t = useTranslations('reviews');
+ const tc = useTranslations('common');
+ const locale = useLocale();
+ const router = useRouter();
+ const { enqueueSnackbar } = useSnackbar();
+
+ const params = useParams<{ id: string }>();
+ const rawId = Number(params.id);
+ const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
+
+ const { data: booking } = useBookingDetail(bookingId, 'customer');
+ const eligibility = useReviewEligibility(bookingId);
+ const myReview = useMyReviewForBooking(bookingId);
+ const createReview = useCreateReview();
+
+ const [rating, setRating] = useState(0);
+ const [body, setBody] = useState('');
+ const [tagCodes, setTagCodes] = useState([]);
+
+ const nurseName = booking?.nurseName?.trim();
+
+ const submit = () => {
+ if (rating < 1) return;
+ createReview.mutate(
+ { bookingId, body: { rating, body: body.trim() || null, tagCodes } },
+ { onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
+ );
+ };
+
+ // ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
+ const existing = myReview.data;
+ const submittedThisSession = createReview.isSuccess;
+ if ((existing && existing.status !== 'none') || submittedThisSession) {
+ const status: ModerationStatus =
+ existing && existing.status !== 'none' ? existing.status : 'pending_moderation';
+ const shownRating = existing && existing.status !== 'none' ? (existing.rating ?? rating) : rating;
+ const shownBody = existing && existing.status !== 'none' ? existing.body : body.trim() || null;
+ const shownTags = existing && existing.status !== 'none' ? existing.tagCodes : tagCodes;
+ return (
+
+
+
+
+
+
+ {existing?.createdAt ? (
+
+ {formatShamsiDate(existing.createdAt, locale)}
+
+ ) : null}
+
+ {status === 'pending_moderation' ? (
+
+ {t('under_review_body')}
+
+ ) : null}
+
+ {shownBody ? {shownBody} : null}
+ {shownTags.length > 0 ? (
+
+ {shownTags.map((code) => (
+
+ ))}
+
+ ) : null}
+
+
+ router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
+ {tc('back')}
+
+
+ );
+ }
+
+ if (eligibility.isLoading || myReview.isLoading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ // ── Not eligible → a clear, non-leaking reason (no form) ─────────────────────────────────────────────────
+ if (!eligibility.data?.canReview) {
+ const reason = eligibility.data?.reason ?? 'not_completed';
+ return (
+
+
+
+
+ {t(`reason_${reason}`)}
+
+
+ router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
+ {tc('back')}
+
+
+ );
+ }
+
+ // ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
+ return (
+
+
+
+
+
+ {t('rating_label')}
+
+
+
+
+ setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
+ multiline
+ minRows={3}
+ fullWidth
+ />
+
+
+
+ {t('tags_label')}
+
+ (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
+ disabled={createReview.isPending}
+ />
+
+
+
+ router.back()} sx={{ m: 0 }} disabled={createReview.isPending}>
+ {tc('cancel')}
+
+
+ {createReview.isPending ? t('submitting') : t('submit')}
+
+
+
+ );
+}
+
+function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
+ return (
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx
new file mode 100644
index 0000000..101d4a3
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx
@@ -0,0 +1,546 @@
+'use client';
+import { useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { useParams, useRouter } from 'next/navigation';
+import { useSnackbar } from 'notistack';
+import {
+ Checkbox,
+ FormControlLabel,
+ IconButton,
+ Paper,
+ Skeleton,
+ Stack,
+ Tab,
+ Tabs,
+ TextField,
+ Typography,
+} from '@mui/material';
+import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
+import { ROUTES } from '@/constants';
+import { formatShamsiDate } from '@/utils';
+import { usePatient } from '@/services/patients';
+import { birthDateToAge } from '@/services/patients/age';
+import { useRecordAccess, usePatientCareRecord, usePatientHistory, useUpdateCareRecord } from '@/services/patientRecords';
+import { CARE_RECORD_TABS } from '@/services/patientRecords/types';
+import type {
+ CareRecordTab,
+ CareTask,
+ FamilyCareRecord,
+ Medication,
+ RoutineItem,
+} from '@/services/patientRecords/types';
+
+/**
+ * E2 — Patient care-record viewer (پروندهٔ مراقبت). The **family-owned, patient-scoped** record with four
+ * tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks; the **سوابق**
+ * (nurse visit notes) are read-only to everyone. A persistent ownership banner states the record belongs to
+ * the family. The clinical-access check gates the whole screen (a `403` → a clear, non-leaking access-denied
+ * card — never partial clinical data).
+ */
+export default function PatientRecordPage() {
+ const t = useTranslations('records');
+ // Shared enum labels are reused from the onboarding/patients namespaces — never re-keyed.
+ const to = useTranslations('onboarding');
+ const tp = useTranslations('patients');
+
+ const params = useParams<{ id: string }>();
+ const rawId = Number(params.id);
+ const patientId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
+
+ const access = useRecordAccess(patientId);
+ const canView = access.data?.canView ?? false;
+ const patient = usePatient(patientId, { enabled: canView });
+
+ const [tab, setTab] = useState('medications');
+
+ if (access.isLoading) return ;
+
+ // Access-denied — a clear, non-leaking card (never any clinical data).
+ if (access.data && !access.data.canView) {
+ return (
+
+
+
+
+ {t('access_denied_title')}
+
+
+ {t('access_denied_body')}
+
+
+
+
+ );
+ }
+
+ if (patient.isLoading) return ;
+
+ if (patient.isError || !patient.data) {
+ return (
+
+
+
+ {t('not_found_title')}
+
+
+ {t('not_found_body')}
+
+
+
+
+ );
+ }
+
+ const p = patient.data;
+ const age = birthDateToAge(p.birthDate);
+
+ return (
+
+ to(`condition_${code}`))}
+ noConditionsLabel={tp('conditions_none')}
+ />
+
+
+
+
+ {t('ownership_banner')}
+
+
+
+ setTab(next)}
+ variant="scrollable"
+ scrollButtons="auto"
+ allowScrollButtonsMobile
+ sx={{ borderBottom: 1, borderColor: 'divider' }}
+ >
+ {CARE_RECORD_TABS.map((value) => (
+
+ ))}
+
+
+ {tab === 'history' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** The three customer-editable tabs (medications/routine/tasks) share one record read + one save mutation. */
+function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: CareRecordTab; canEdit: boolean }) {
+ const t = useTranslations('records');
+ const { enqueueSnackbar } = useSnackbar();
+ const record = usePatientCareRecord(patientId);
+ const update = useUpdateCareRecord(patientId);
+
+ if (record.isLoading) {
+ return (
+
+
+
+
+ );
+ }
+ if (record.isError || !record.data) {
+ return (
+
+ {t('load_error')}
+
+ );
+ }
+
+ const data = record.data;
+ const save = (patch: Partial>, onDone: () => void) => {
+ update.mutate(patch, {
+ onSuccess: () => {
+ enqueueSnackbar(t('saved'), { variant: 'success' });
+ onDone();
+ },
+ onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
+ });
+ };
+
+ if (tab === 'medications') {
+ return save({ medications: meds }, done)} />;
+ }
+ if (tab === 'routine') {
+ return save({ routine: items }, done)} />;
+ }
+ return save({ tasks }, done)} />;
+}
+
+// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
+function MedicationsTab({
+ data,
+ canEdit,
+ saving,
+ onSave,
+}: {
+ data: Medication[];
+ canEdit: boolean;
+ saving: boolean;
+ onSave: (medications: Medication[], onDone: () => void) => void;
+}) {
+ const t = useTranslations('records');
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(data);
+
+ const startEdit = () => {
+ setDraft(data.map((m) => ({ ...m })));
+ setEditing(true);
+ };
+ const update = (id: string, patch: Partial) => setDraft((d) => d.map((m) => (m.id === id ? { ...m, ...patch } : m)));
+ const remove = (id: string) => setDraft((d) => d.filter((m) => m.id !== id));
+ const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, name: '', dosage: null, frequency: '', timingNote: null }]);
+ const canSave = draft.every((m) => m.name.trim().length > 0);
+
+ if (!editing) {
+ return (
+
+ {data.map((m) => (
+
+
+
+
+ {m.name}
+ {m.dosage ? ` — ${m.dosage}` : ''}
+
+
+ {(m.frequency || m.timingNote) && (
+
+ {[m.frequency, m.timingNote].filter(Boolean).join(' · ')}
+
+ )}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {draft.map((m) => (
+
+
+
+ update(m.id, { name: e.target.value })} size="small" fullWidth required />
+ remove(m.id)} size="small">
+
+
+
+
+ update(m.id, { dosage: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
+ update(m.id, { frequency: e.target.value })} size="small" sx={{ flex: 1, minWidth: 120 }} />
+
+ update(m.id, { timingNote: e.target.value || null })} size="small" fullWidth />
+
+
+ ))}
+ setEditing(false)}
+ onSave={() => onSave(draft, () => setEditing(false))}
+ saving={saving}
+ canSave={canSave}
+ />
+
+ );
+}
+
+// ── Routine ───────────────────────────────────────────────────────────────────────────────────────────────
+function RoutineTab({
+ data,
+ canEdit,
+ saving,
+ onSave,
+}: {
+ data: RoutineItem[];
+ canEdit: boolean;
+ saving: boolean;
+ onSave: (routine: RoutineItem[], onDone: () => void) => void;
+}) {
+ const t = useTranslations('records');
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(data);
+
+ const startEdit = () => {
+ setDraft(data.map((r) => ({ ...r })));
+ setEditing(true);
+ };
+ const update = (id: string, patch: Partial) => setDraft((d) => d.map((r) => (r.id === id ? { ...r, ...patch } : r)));
+ const remove = (id: string) => setDraft((d) => d.filter((r) => r.id !== id));
+ const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', timeOfDay: null, note: null }]);
+ const canSave = draft.every((r) => r.label.trim().length > 0);
+
+ if (!editing) {
+ return (
+
+ {data.map((r) => (
+
+
+
+
+ {r.label}
+ {r.timeOfDay ? ` — ${r.timeOfDay}` : ''}
+
+
+ {r.note ? (
+
+ {r.note}
+
+ ) : null}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {draft.map((r) => (
+
+
+
+ update(r.id, { label: e.target.value })} size="small" fullWidth required />
+ remove(r.id)} size="small">
+
+
+
+
+ update(r.id, { timeOfDay: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
+ update(r.id, { note: e.target.value || null })} size="small" sx={{ flex: 2, minWidth: 120 }} />
+
+
+
+ ))}
+ setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
+
+ );
+}
+
+// ── Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────
+function TasksTab({
+ data,
+ canEdit,
+ saving,
+ onSave,
+}: {
+ data: CareTask[];
+ canEdit: boolean;
+ saving: boolean;
+ onSave: (tasks: CareTask[], onDone: () => void) => void;
+}) {
+ const t = useTranslations('records');
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(data);
+
+ const startEdit = () => {
+ setDraft(data.map((task) => ({ ...task })));
+ setEditing(true);
+ };
+ const update = (id: string, patch: Partial) => setDraft((d) => d.map((task) => (task.id === id ? { ...task, ...patch } : task)));
+ const remove = (id: string) => setDraft((d) => d.filter((task) => task.id !== id));
+ const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', done: false }]);
+ const canSave = draft.every((task) => task.label.trim().length > 0);
+
+ if (!editing) {
+ return (
+
+ {data.map((task) => (
+
+
+
+
+ {task.label}
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {draft.map((task) => (
+
+
+ update(task.id, { done: e.target.checked })} />}
+ label=""
+ sx={{ m: 0 }}
+ aria-label={t('task_done')}
+ />
+ update(task.id, { label: e.target.value })} size="small" fullWidth required />
+ remove(task.id)} size="small">
+
+
+
+
+ ))}
+ setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
+
+ );
+}
+
+// ── History (سوابق) — patient-scoped, read-only, paged ────────────────────────────────────────────────────
+function HistoryTab({ patientId }: { patientId: number }) {
+ const t = useTranslations('records');
+ const locale = useLocale();
+ const [page, setPage] = useState(1);
+ const history = usePatientHistory(patientId, page);
+
+ if (history.isLoading) {
+ return (
+
+
+
+
+ );
+ }
+ if (history.isError || !history.data) {
+ return (
+
+ {t('load_error')}
+
+ );
+ }
+
+ const { items, total, pageSize } = history.data;
+ if (items.length === 0) {
+ return (
+
+ {t('history_empty')}
+
+ );
+ }
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ return (
+
+ {items.map((note) => (
+
+ ))}
+ {totalPages > 1 ? (
+
+ setPage((n) => Math.max(1, n - 1))} sx={{ m: 0 }}>
+ {t('prev')}
+
+
+ {t('page_of', { page, total: totalPages })}
+
+ = totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)} sx={{ m: 0 }}>
+ {t('next')}
+
+
+ ) : null}
+
+ );
+}
+
+// ── Small shared bits ─────────────────────────────────────────────────────────────────────────────────────
+function SectionShell({
+ canEdit,
+ onEdit,
+ empty,
+ emptyLabel,
+ children,
+}: {
+ canEdit: boolean;
+ onEdit: () => void;
+ empty: boolean;
+ emptyLabel: string;
+ children: React.ReactNode;
+}) {
+ const tc = useTranslations('records');
+ return (
+
+ {canEdit ? (
+
+ {tc('edit')}
+
+ ) : null}
+ {empty ? (
+
+ {emptyLabel}
+
+ ) : (
+ {children}
+ )}
+
+ );
+}
+
+function EditActions({
+ onAdd,
+ onCancel,
+ onSave,
+ saving,
+ canSave,
+}: {
+ onAdd: () => void;
+ onCancel: () => void;
+ onSave: () => void;
+ saving: boolean;
+ canSave: boolean;
+}) {
+ const t = useTranslations('records');
+ const tc = useTranslations('common');
+ return (
+
+
+ {t('add_item')}
+
+
+
+ {tc('cancel')}
+
+
+ {saving ? tc('saving') : tc('save')}
+
+
+
+ );
+}
+
+function BackToPatients() {
+ const t = useTranslations('records');
+ const router = useRouter();
+ const locale = useLocale();
+ return (
+ router.push(`/${locale}${ROUTES.PATIENTS}`)}
+ sx={{ m: 0, alignSelf: 'flex-start' }}
+ >
+ {t('back_to_patients')}
+
+ );
+}
+
+function RecordSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx
index e18ce0d..9313623 100644
--- a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
-import { useTranslations } from 'next-intl';
+import { useLocale, useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -14,6 +15,7 @@ import {
Typography,
} from '@mui/material';
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
+import { patientRecordPath } from '@/constants';
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
import { birthDateToAge } from '@/services/patients/age';
import type { CreatePatientInput, Patient } from '@/services/patients/types';
@@ -27,6 +29,8 @@ export default function PatientsPage() {
const t = useTranslations('patients');
const to = useTranslations('onboarding');
const tc = useTranslations('common');
+ const router = useRouter();
+ const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = usePatients();
@@ -141,6 +145,8 @@ export default function PatientsPage() {
ageLabel={age == null ? undefined : t('age_years', { age })}
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={t('conditions_none')}
+ onOpen={() => router.push(`/${locale}${patientRecordPath(patient.id)}`)}
+ openLabel={t('open_record', { name: patient.displayName })}
onEdit={() => openEdit(patient)}
onArchive={() => setArchiveTarget(patient)}
editLabel={t('edit')}
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx
index f1cf7bc..3876d25 100644
--- a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx
@@ -1,19 +1,24 @@
'use client';
+import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
-import { Avatar, Box, Chip, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
-import { AppButton, AppIcon, ServicePriceRow, TrustBadge } from '@/components';
+import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
+import { AppButton, AppIcon, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
+import { useNurseReviews } from '@/services/reviews';
+import type { ReviewListItem } from '@/services/reviews/types';
+
+type ProfileTab = 'services' | 'reviews';
/**
* C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute
- * chips, the priced services list (ServicePriceRow), and the latest-review snippet. The primary CTA
- * "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` + city/category to the
- * f7 booking route (the form itself is DEFERRED → f7). States: loading skeleton, not-found, error/retry.
+ * chips, and a **tabbed** body — «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
+ * tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
+ * The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
*/
export default function NurseProfilePage() {
const t = useTranslations('search');
@@ -26,6 +31,7 @@ export default function NurseProfilePage() {
const { data: profile, isLoading, isError, error, refetch } = useNurseProfile(
Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined,
);
+ const [tab, setTab] = useState('services');
if (isLoading) return ;
@@ -75,8 +81,13 @@ export default function NurseProfilePage() {
-
-
+
+ setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
+
+
+
+
+ {tab === 'services' ? : }
-
- {t('services_title')}
-
{profile.services.length === 0 ? (
{t('services_empty')}
@@ -196,39 +204,112 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
);
}
-function LatestReview({ profile }: { profile: NurseProfile }) {
- const t = useTranslations('search');
+/**
+ * The f13 reviews tab — the aggregate rating + count and an infinite list of **published** reviews. Never
+ * requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
+ * recomputed value, not a client sum.
+ */
+function ReviewsPanel({ nurseId }: { nurseId: number }) {
+ const t = useTranslations('reviews');
const locale = useLocale();
- const review = profile.latestReview;
+ const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useNurseReviews(nurseId);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+ {t('load_error')}
+
+ refetch()} sx={{ m: 0 }}>
+ {t('retry')}
+
+
+ );
+ }
+
+ const aggregate = data?.pages[0]?.aggregate;
+ const items = data?.pages.flatMap((page) => page.reviews.items) ?? [];
+ const publishedCount = aggregate?.publishedCount ?? 0;
+
+ if (publishedCount === 0) {
+ return (
+
+ {t('reviews_empty')}
+
+ );
+ }
+
+ const average = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ }).format(aggregate?.averageRating ?? 0);
return (
-
-
- {t('latest_review_title')}
-
- {!review ? (
-
- {t('no_reviews')}
+
+
+
+
+ {average}
- ) : (
-
-
-
-
- {new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(review.rating)}
-
-
-
- {review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
-
-
- {review.body}
-
- )}
+
+ {t('count', { count: publishedCount })}
+
+
+
+
+ {items.map((review) => (
+
+ ))}
+
+
+ {hasNextPage ? (
+ fetchNextPage()}
+ disabled={isFetchingNextPage}
+ sx={{ m: 0, alignSelf: 'center' }}
+ >
+ {isFetchingNextPage ? t('loading') : t('load_more')}
+
+ ) : null}
);
}
+function ReviewCard({ review }: { review: ReviewListItem }) {
+ const t = useTranslations('reviews');
+ const locale = useLocale();
+ return (
+
+
+
+
+ {t('author_masked')} · {formatShamsiDate(review.createdAt, locale)}
+
+
+ {review.body ? {review.body} : null}
+ {review.tagCodes.length > 0 ? (
+
+ {review.tagCodes.map((code) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
function ProfileSkeleton() {
return (
diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx
new file mode 100644
index 0000000..2426717
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx
@@ -0,0 +1,156 @@
+'use client';
+import { useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { useSnackbar } from 'notistack';
+import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
+import { AppButton, AppIcon, VisitNoteCard } from '@/components';
+import { formatShamsiDate } from '@/utils';
+import { useBookingDetail } from '@/services/bookings';
+import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
+import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisitNote } from '@/services/patientRecords';
+import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
+import type { TaskResult } from '@/services/patientRecords/types';
+
+/**
+ * E3 (نمای پرستار) — the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking
+ * detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits
+ * ONE appended note. It exposes **no** medication/routine/task editing and never wires `updateCareRecord` —
+ * append-only is a hard boundary. The nurse can read prior history for continuity (patient-scoped, so it
+ * persists across nurse changes). The composer hides when the nurse lacks append access.
+ */
+export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number }) {
+ const t = useTranslations('records');
+ const tc = useTranslations('common');
+ const locale = useLocale();
+ const { enqueueSnackbar } = useSnackbar();
+
+ const booking = useBookingDetail(bookingId, 'nurse');
+ const status = booking.data?.status;
+ const patientId = booking.data?.patientId ?? -1;
+ const engaged = status ? isBookingConfirmedOrBeyond(status) : false;
+
+ const access = useRecordAccess(patientId, { enabled: engaged && patientId > 0 });
+ const canAppend = access.data?.canAppendNote ?? false;
+ const canView = access.data?.canView ?? false;
+
+ const record = usePatientCareRecord(patientId, { enabled: engaged && patientId > 0 && canAppend });
+ const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
+ const createNote = useCreateVisitNote(patientId);
+
+ const [note, setNote] = useState('');
+ const [checked, setChecked] = useState>({});
+
+ if (!booking.data || !engaged) return null;
+
+ const tasks = record.data?.tasks ?? [];
+
+ const submit = () => {
+ if (!note.trim()) {
+ enqueueSnackbar(t('note_required'), { variant: 'error' });
+ return;
+ }
+ const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
+ createNote.mutate(
+ { bookingId, body: note.trim(), taskResults },
+ {
+ onSuccess: () => {
+ enqueueSnackbar(t('note_saved'), { variant: 'success' });
+ setNote('');
+ setChecked({});
+ },
+ onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
+ },
+ );
+ };
+
+ const historyItems = history.data?.items ?? [];
+
+ return (
+
+ {canAppend ? (
+
+
+
+
+
+ {t('notes_title')}
+
+
+
+ {record.isLoading || tasks.length > 0 ? (
+
+
+ {t('tasks_checklist_title')}
+
+ {record.isLoading ? (
+
+ ) : (
+ tasks.map((task) => (
+ setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
+ />
+ }
+ label={task.label}
+ />
+ ))
+ )}
+
+ ) : null}
+
+ setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
+ multiline
+ minRows={3}
+ fullWidth
+ />
+
+
+ {createNote.isPending ? tc('saving') : t('note_submit')}
+
+
+
+ ) : null}
+
+ {canView ? (
+
+
+ {t('continuity_title')}
+
+ {history.isLoading ? (
+
+ ) : historyItems.length === 0 ? (
+
+ {t('history_empty')}
+
+ ) : (
+ historyItems.map((visitNote) => (
+
+ ))
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx
index d0a8892..b36b947 100644
--- a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx
@@ -1,14 +1,25 @@
'use client';
import { useParams } from 'next/navigation';
+import { Stack } from '@mui/material';
import { BookingDetailView } from '@/components/booking';
+import NurseVisitNotesPanel from './NurseVisitNotesPanel';
/**
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
- * (two-stage disclosure). Reached from the ویزیت امروز day surface.
+ * (two-stage disclosure). Below the EVV surface, the f13 append-only visit-note panel lets the nurse tick
+ * today's task checklist, write a note, and read the patient's continuity history. Reached from the ویزیت
+ * امروز day surface.
*/
export default function NurseBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
- return 0 ? id : -1} viewerRole="nurse" />;
+ const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
+
+ return (
+
+
+ {bookingId > 0 && }
+
+ );
}
diff --git a/client/src/components/PatientCard/PatientCard.test.tsx b/client/src/components/PatientCard/PatientCard.test.tsx
index 3e1ddd6..098f180 100644
--- a/client/src/components/PatientCard/PatientCard.test.tsx
+++ b/client/src/components/PatientCard/PatientCard.test.tsx
@@ -18,7 +18,7 @@ const PATIENT: Patient = {
conditions: ['elderly'],
};
-function renderCard() {
+function renderCard(extra: { onOpen?: () => void; openLabel?: string } = {}) {
const onEdit = jest.fn();
const onArchive = jest.fn();
render(
@@ -34,6 +34,7 @@ function renderCard() {
onArchive={onArchive}
editLabel="Edit"
archiveLabel="Archive"
+ {...extra}
/>
,
);
@@ -57,4 +58,14 @@ describe(' component', () => {
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onArchive).toHaveBeenCalledTimes(1);
});
+
+ it('opens the record from the identity area without firing edit/archive', async () => {
+ const user = userEvent.setup();
+ const onOpen = jest.fn();
+ const { onEdit, onArchive } = renderCard({ onOpen, openLabel: 'Open record' });
+ await user.click(screen.getByLabelText('Open record'));
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ expect(onEdit).not.toHaveBeenCalled();
+ expect(onArchive).not.toHaveBeenCalled();
+ });
});
diff --git a/client/src/components/PatientCard/PatientCard.tsx b/client/src/components/PatientCard/PatientCard.tsx
index 3e3e3bc..298c8f5 100644
--- a/client/src/components/PatientCard/PatientCard.tsx
+++ b/client/src/components/PatientCard/PatientCard.tsx
@@ -1,11 +1,10 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
-import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
-import Typography from '@mui/material/Typography';
import { AppIconButton } from '@/components/common';
+import PatientHeader from '@/components/PatientHeader';
import type { Patient } from '@/services/patients/types';
export interface PatientCardProps {
@@ -23,11 +22,17 @@ export interface PatientCardProps {
onArchive: () => void;
editLabel: string;
archiveLabel: string;
+ /** When provided, tapping the identity area opens the patient's record (E2). The action buttons are unaffected. */
+ onOpen?: () => void;
+ /** Accessible label for the tappable identity area (required when `onOpen` is set). */
+ openLabel?: string;
}
/**
- * Patient summary card for the E1 list — relation + name, age/gender, and condition chips,
- * with edit and archive actions. All display text is translated by the caller.
+ * Patient summary card for the E1 list — the shared `PatientHeader` (relation + name, age/gender, condition
+ * chips) plus edit and archive actions. When `onOpen` is passed the identity area becomes a button that opens
+ * the E2 record viewer; the edit/archive icon buttons keep their own handlers. All display text is translated
+ * by the caller.
* @component PatientCard
*/
const PatientCard: FunctionComponent = ({
@@ -41,40 +46,46 @@ const PatientCard: FunctionComponent = ({
onArchive,
editLabel,
archiveLabel,
+ onOpen,
+ openLabel,
}) => {
- const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
+ const header = (
+
+ );
return (
-
-
-
- {patient.displayName}
-
- {relationLabel ? (
-
- ) : null}
-
-
- {meta ? (
-
- {meta}
-
- ) : null}
-
- {conditionLabels.length > 0 ? (
-
- {conditionLabels.map((label) => (
-
- ))}
-
- ) : (
-
- {noConditionsLabel}
-
- )}
-
+ {onOpen ? (
+
+ {header}
+
+ ) : (
+ {header}
+ )}
diff --git a/client/src/components/PatientHeader/PatientHeader.test.tsx b/client/src/components/PatientHeader/PatientHeader.test.tsx
new file mode 100644
index 0000000..fa1c3c2
--- /dev/null
+++ b/client/src/components/PatientHeader/PatientHeader.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { ThemeProvider } from '../../theme';
+import PatientHeader, { PatientHeaderProps } from './PatientHeader';
+
+function renderHeader(props: Partial = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe(' component', () => {
+ it('renders name, relation, meta line and conditions', () => {
+ renderHeader();
+ expect(screen.getByText('Zahra Mohammadi')).toBeInTheDocument();
+ expect(screen.getByText('Parent')).toBeInTheDocument();
+ expect(screen.getByText('70 yrs · Female')).toBeInTheDocument();
+ expect(screen.getByText('Elderly')).toBeInTheDocument();
+ });
+
+ it('shows the no-conditions caption when there are none', () => {
+ renderHeader({ conditionLabels: [] });
+ expect(screen.getByText('No conditions')).toBeInTheDocument();
+ });
+
+ it('omits the relation chip when no relation is set', () => {
+ renderHeader({ relationLabel: undefined });
+ expect(screen.queryByText('Parent')).not.toBeInTheDocument();
+ // meta line still renders age · gender
+ expect(screen.getByText('70 yrs · Female')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/PatientHeader/PatientHeader.tsx b/client/src/components/PatientHeader/PatientHeader.tsx
new file mode 100644
index 0000000..6e57f01
--- /dev/null
+++ b/client/src/components/PatientHeader/PatientHeader.tsx
@@ -0,0 +1,71 @@
+'use client';
+import { FunctionComponent } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+
+export interface PatientHeaderProps {
+ /** The patient's display name. */
+ displayName: string;
+ /** Translated relation label; omitted when the patient has no relation set. */
+ relationLabel?: string;
+ /** Translated gender label. */
+ genderLabel: string;
+ /** Translated age label (e.g. "70 yrs"); omitted when the birth date is unknown. */
+ ageLabel?: string;
+ /** Translated condition labels; empty renders the "no conditions" line. */
+ conditionLabels: string[];
+ noConditionsLabel: string;
+}
+
+/**
+ * The patient identity block — bold name + relation chip, a secondary "age · gender" meta line, and outlined
+ * condition chips (or a "no conditions" caption). Extracted from `PatientCard` so the E1 list card and the E2
+ * record viewer render the exact same header. Purely presentational; the caller translates every label and
+ * tolerates a missing relation / empty conditions (both are client-augmented, may be empty on a real read).
+ * @component PatientHeader
+ */
+const PatientHeader: FunctionComponent = ({
+ displayName,
+ relationLabel,
+ genderLabel,
+ ageLabel,
+ conditionLabels,
+ noConditionsLabel,
+}) => {
+ const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
+
+ return (
+
+
+
+ {displayName}
+
+ {relationLabel ? (
+
+ ) : null}
+
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+
+ {conditionLabels.length > 0 ? (
+
+ {conditionLabels.map((label) => (
+
+ ))}
+
+ ) : (
+
+ {noConditionsLabel}
+
+ )}
+
+ );
+};
+
+export default PatientHeader;
diff --git a/client/src/components/PatientHeader/index.tsx b/client/src/components/PatientHeader/index.tsx
new file mode 100644
index 0000000..08f5090
--- /dev/null
+++ b/client/src/components/PatientHeader/index.tsx
@@ -0,0 +1,4 @@
+import PatientHeader from './PatientHeader';
+
+export type { PatientHeaderProps } from './PatientHeader';
+export { PatientHeader as default, PatientHeader };
diff --git a/client/src/components/RatingInput/RatingInput.test.tsx b/client/src/components/RatingInput/RatingInput.test.tsx
new file mode 100644
index 0000000..2223322
--- /dev/null
+++ b/client/src/components/RatingInput/RatingInput.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider } from '../../theme';
+import RatingInput, { RatingInputProps } from './RatingInput';
+
+function renderRating(props: Partial = {}) {
+ const onChange = jest.fn();
+ const utils = render(
+
+
+ ,
+ );
+ return { ...utils, onChange };
+}
+
+describe(' component', () => {
+ it('renders max stars and exposes the current value', () => {
+ const { container } = renderRating({ value: 3 });
+ expect(container.querySelector('[data-rating="3"]')).toBeInTheDocument();
+ expect(container.querySelectorAll('[data-star]')).toHaveLength(5);
+ });
+
+ it('calls onChange with the clicked star value', async () => {
+ const user = userEvent.setup();
+ const { onChange } = renderRating({ value: 0 });
+ await user.click(screen.getByRole('radio', { name: '4' }));
+ expect(onChange).toHaveBeenCalledWith(4);
+ });
+
+ it('reflects the chosen value on the matching star', () => {
+ renderRating({ value: 2 });
+ expect(screen.getByRole('radio', { name: '2' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('radio', { name: '3' })).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('is a non-interactive display with no radios when readOnly', () => {
+ const { container, onChange } = renderRating({ value: 5, readOnly: true });
+ expect(container.querySelector('[data-rating="5"]')).toBeInTheDocument();
+ expect(container.querySelectorAll('[data-star]')).toHaveLength(0);
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/RatingInput/RatingInput.tsx b/client/src/components/RatingInput/RatingInput.tsx
new file mode 100644
index 0000000..226e27d
--- /dev/null
+++ b/client/src/components/RatingInput/RatingInput.tsx
@@ -0,0 +1,76 @@
+'use client';
+import { FunctionComponent } from 'react';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import { AppIcon } from '@/components/common';
+
+export interface RatingInputProps {
+ /** Current rating, 0–max (0 = none chosen). */
+ value: number;
+ /** Called with the clicked star value. Omit (or set `readOnly`) for a display-only star row. */
+ onChange?: (value: number) => void;
+ /** Non-interactive display (e.g. a review card). */
+ readOnly?: boolean;
+ /** Number of stars (default 5). */
+ max?: number;
+ /** Icon size in px (default 28 interactive / caller-set for display). */
+ size?: number;
+ /** Accessible name for the whole control — translated by the caller. */
+ ariaLabel?: string;
+}
+
+const DEFAULT_MAX = 5;
+const DEFAULT_SIZE = 28;
+
+/**
+ * The 1–max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is a
+ * `radio` in a `radiogroup`); otherwise a static star row (`role="img"`). Stars are token-coloured — filled
+ * = `var(--bal-warning)` (matching the existing rating star), empty = `var(--bal-divider)` — so it switches
+ * with the color scheme and never hard-codes a hex. Built on the registered `star` `AppIcon` (no MUI `Rating`
+ * dependency, so the control is fully deterministic to test).
+ * @component RatingInput
+ */
+const RatingInput: FunctionComponent = ({
+ value,
+ onChange,
+ readOnly = false,
+ max = DEFAULT_MAX,
+ size = DEFAULT_SIZE,
+ ariaLabel,
+}) => {
+ const interactive = !readOnly && Boolean(onChange);
+ const stars = Array.from({ length: max }, (_, i) => i + 1);
+
+ return (
+
+ {stars.map((n) => {
+ const color = n <= value ? 'var(--bal-warning)' : 'var(--bal-divider)';
+ if (!interactive) {
+ return ;
+ }
+ return (
+ onChange?.(n)}
+ sx={{ p: 0.25 }}
+ >
+
+
+ );
+ })}
+
+ );
+};
+
+export default RatingInput;
diff --git a/client/src/components/RatingInput/index.tsx b/client/src/components/RatingInput/index.tsx
new file mode 100644
index 0000000..be68aea
--- /dev/null
+++ b/client/src/components/RatingInput/index.tsx
@@ -0,0 +1,4 @@
+import RatingInput from './RatingInput';
+
+export type { RatingInputProps } from './RatingInput';
+export { RatingInput as default, RatingInput };
diff --git a/client/src/components/ReviewTagSelector/ReviewTagSelector.test.tsx b/client/src/components/ReviewTagSelector/ReviewTagSelector.test.tsx
new file mode 100644
index 0000000..3f02053
--- /dev/null
+++ b/client/src/components/ReviewTagSelector/ReviewTagSelector.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider } from '../../theme';
+import ReviewTagSelector, { ReviewTagSelectorProps } from './ReviewTagSelector';
+
+const CODES = ['punctual', 'kind', 'clean'] as const;
+const LABELS: Record = { punctual: 'Punctual', kind: 'Kind', clean: 'Clean' };
+
+function renderSelector(props: Partial = {}) {
+ const onChange = jest.fn();
+ const utils = render(
+
+ LABELS[code] ?? code}
+ {...props}
+ />
+ ,
+ );
+ return { ...utils, onChange };
+}
+
+describe(' component', () => {
+ it('renders a chip per code with its label', () => {
+ renderSelector();
+ expect(screen.getByText('Punctual')).toBeInTheDocument();
+ expect(screen.getByText('Kind')).toBeInTheDocument();
+ expect(screen.getByText('Clean')).toBeInTheDocument();
+ });
+
+ it('adds a code to the selection when an unselected chip is clicked', async () => {
+ const user = userEvent.setup();
+ const { onChange } = renderSelector({ selected: ['punctual'] });
+ await user.click(screen.getByText('Kind'));
+ expect(onChange).toHaveBeenCalledWith(['punctual', 'kind']);
+ });
+
+ it('removes a code when a selected chip is clicked', async () => {
+ const user = userEvent.setup();
+ const { onChange } = renderSelector({ selected: ['punctual', 'kind'] });
+ await user.click(screen.getByText('Punctual'));
+ expect(onChange).toHaveBeenCalledWith(['kind']);
+ });
+
+ it('marks selected chips via aria-pressed', () => {
+ renderSelector({ selected: ['clean'] });
+ const cleanChip = screen.getByText('Clean').closest('[aria-pressed]');
+ expect(cleanChip).toHaveAttribute('aria-pressed', 'true');
+ });
+});
diff --git a/client/src/components/ReviewTagSelector/ReviewTagSelector.tsx b/client/src/components/ReviewTagSelector/ReviewTagSelector.tsx
new file mode 100644
index 0000000..d76ab8a
--- /dev/null
+++ b/client/src/components/ReviewTagSelector/ReviewTagSelector.tsx
@@ -0,0 +1,58 @@
+'use client';
+import { FunctionComponent } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+
+export interface ReviewTagSelectorProps {
+ /** The stable tag codes to offer (the review vocabulary). */
+ codes: readonly string[];
+ /** Currently-selected codes. */
+ selected: string[];
+ /** Called with the next selection when a chip is toggled. */
+ onChange: (selected: string[]) => void;
+ /** Maps a code → its display label (the caller owns i18n; labels are keys off the code, never the wire). */
+ labelFor: (code: string) => string;
+ disabled?: boolean;
+}
+
+/**
+ * A multi-select chip group for the review tags (منظم/حرفهای/…). Selected chips use the brand primary
+ * (MUI palette — no hard-coded hex); unselected are outlined. The component is i18n-free: the caller supplies
+ * `labelFor(code)`, keeping chip labels keyed off the stable code and never off the wire.
+ * @component ReviewTagSelector
+ */
+const ReviewTagSelector: FunctionComponent = ({
+ codes,
+ selected,
+ onChange,
+ labelFor,
+ disabled = false,
+}) => {
+ const toggle = (code: string) => {
+ onChange(selected.includes(code) ? selected.filter((c) => c !== code) : [...selected, code]);
+ };
+
+ return (
+
+ {codes.map((code) => {
+ const isSelected = selected.includes(code);
+ return (
+ toggle(code)}
+ aria-pressed={isSelected}
+ data-selected={isSelected ? 'true' : 'false'}
+ sx={{ fontWeight: isSelected ? 600 : 400 }}
+ />
+ );
+ })}
+
+ );
+};
+
+export default ReviewTagSelector;
diff --git a/client/src/components/ReviewTagSelector/index.tsx b/client/src/components/ReviewTagSelector/index.tsx
new file mode 100644
index 0000000..ca497cb
--- /dev/null
+++ b/client/src/components/ReviewTagSelector/index.tsx
@@ -0,0 +1,4 @@
+import ReviewTagSelector from './ReviewTagSelector';
+
+export type { ReviewTagSelectorProps } from './ReviewTagSelector';
+export { ReviewTagSelector as default, ReviewTagSelector };
diff --git a/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx b/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx
new file mode 100644
index 0000000..7cc15d7
--- /dev/null
+++ b/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { ThemeProvider } from '../../theme';
+import VisitNoteCard from './VisitNoteCard';
+import type { VisitNote } from '@/services/patientRecords/types';
+
+const NOTE: VisitNote = {
+ id: 8100,
+ bookingId: 5005,
+ nurseProfileId: 1,
+ nurseDisplayName: 'مریم رضایی',
+ body: 'وضعیت پایدار بود؛ داروها داده شد.',
+ taskResults: [
+ { label: 'دادن متفورمین', done: true },
+ { label: 'پیادهروی کوتاه', done: false },
+ ],
+ recordedAt: '2026-06-01T09:00:00Z',
+};
+
+function renderCard(note: VisitNote = NOTE) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe(' component', () => {
+ it('renders the nurse name, date and body', () => {
+ renderCard();
+ expect(screen.getByText('مریم رضایی')).toBeInTheDocument();
+ expect(screen.getByText('۱ خرداد ۱۴۰۵')).toBeInTheDocument();
+ expect(screen.getByText('وضعیت پایدار بود؛ داروها داده شد.')).toBeInTheDocument();
+ });
+
+ it('renders each ticked task as a chip', () => {
+ renderCard();
+ expect(screen.getByText('دادن متفورمین')).toBeInTheDocument();
+ expect(screen.getByText('پیادهروی کوتاه')).toBeInTheDocument();
+ });
+
+ it('falls back to the provided author label when the nurse name is missing', () => {
+ renderCard({ ...NOTE, nurseDisplayName: null });
+ expect(screen.getByText('پرستار')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/VisitNoteCard/VisitNoteCard.tsx b/client/src/components/VisitNoteCard/VisitNoteCard.tsx
new file mode 100644
index 0000000..3430265
--- /dev/null
+++ b/client/src/components/VisitNoteCard/VisitNoteCard.tsx
@@ -0,0 +1,68 @@
+'use client';
+import { FunctionComponent } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { AppIcon } from '@/components/common';
+import type { VisitNote } from '@/services/patientRecords/types';
+
+export interface VisitNoteCardProps {
+ /** One nurse-authored visit note (read-only from the client's perspective). */
+ note: VisitNote;
+ /** Pre-formatted Shamsi date/time — the caller owns locale. */
+ dateLabel: string;
+ /** Shown when the note has no recorded nurse name. */
+ authorFallback: string;
+}
+
+/**
+ * One read-only visit note in the longitudinal history — the nurse's display name, the Shamsi date, the note
+ * body, and (if any) the checklist the nurse ticked as done/not-done chips. Purely presentational (the caller
+ * formats the date and supplies the author fallback); reused by the customer سوابق tab and the nurse
+ * continuity view. Clinical text is rendered verbatim and never logged.
+ * @component VisitNoteCard
+ */
+const VisitNoteCard: FunctionComponent = ({ note, dateLabel, authorFallback }) => {
+ return (
+
+
+
+
+ {note.nurseDisplayName?.trim() || authorFallback}
+
+
+ {dateLabel}
+
+
+
+
+ {note.body}
+
+
+ {note.taskResults.length > 0 ? (
+
+ {note.taskResults.map((task, index) => (
+
+ }
+ label={task.label}
+ sx={{ color: task.done ? undefined : 'text.secondary' }}
+ />
+ ))}
+
+ ) : null}
+
+ );
+};
+
+export default VisitNoteCard;
diff --git a/client/src/components/VisitNoteCard/index.tsx b/client/src/components/VisitNoteCard/index.tsx
new file mode 100644
index 0000000..90996bf
--- /dev/null
+++ b/client/src/components/VisitNoteCard/index.tsx
@@ -0,0 +1,4 @@
+import VisitNoteCard from './VisitNoteCard';
+
+export type { VisitNoteCardProps } from './VisitNoteCard';
+export { VisitNoteCard as default, VisitNoteCard };
diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts
index 2fa9cd8..6470221 100644
--- a/client/src/components/common/AppIcon/config.ts
+++ b/client/src/components/common/AppIcon/config.ts
@@ -74,6 +74,12 @@ import LockIcon from '@mui/icons-material/LockOutlined';
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
// Payouts — the nurse earnings & payout-history surface (f12/b13)
import EarningsIcon from '@mui/icons-material/PaidOutlined';
+// Reviews & patient care records (f13/b14): visit-note authoring, record tabs, family-ownership banner
+import NotesIcon from '@mui/icons-material/NoteAltOutlined';
+import RoutineIcon from '@mui/icons-material/EventRepeatOutlined';
+import TasksIcon from '@mui/icons-material/ChecklistOutlined';
+import HistoryIcon from '@mui/icons-material/HistoryOutlined';
+import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined';
/**
* List of all available Icon names
@@ -156,4 +162,9 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
lock: LockIcon,
installments: InstallmentsIcon,
earnings: EarningsIcon,
+ notes: NotesIcon,
+ routine: RoutineIcon,
+ tasks: TasksIcon,
+ history: HistoryIcon,
+ family: FamilyIcon,
};
diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx
index 2c95b35..d58ecdb 100644
--- a/client/src/components/index.tsx
+++ b/client/src/components/index.tsx
@@ -29,6 +29,10 @@ import InstallmentScheduleRow from './InstallmentScheduleRow';
import EarningsBalanceHeader from './EarningsBalanceHeader';
import EarningsRow from './EarningsRow';
import PayoutHistoryRow from './PayoutHistoryRow';
+import RatingInput from './RatingInput';
+import ReviewTagSelector from './ReviewTagSelector';
+import VisitNoteCard from './VisitNoteCard';
+import PatientHeader from './PatientHeader';
export {
UserInfo,
@@ -60,6 +64,10 @@ export {
EarningsBalanceHeader,
EarningsRow,
PayoutHistoryRow,
+ RatingInput,
+ ReviewTagSelector,
+ VisitNoteCard,
+ PatientHeader,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -88,3 +96,7 @@ export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
export type { EarningsRowProps } from './EarningsRow';
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
+export type { RatingInputProps } from './RatingInput';
+export type { ReviewTagSelectorProps } from './ReviewTagSelector';
+export type { VisitNoteCardProps } from './VisitNoteCard';
+export type { PatientHeaderProps } from './PatientHeader';
diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts
index 69a0e47..a970b0c 100644
--- a/client/src/constants/routes.ts
+++ b/client/src/constants/routes.ts
@@ -87,5 +87,13 @@ export const nursePayoutDetailPath = (payoutId: number | string): string =>
export const nurseBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.NURSE_VISITS}/${bookingId}`;
+/** The leave-a-review flow (f13) — the "ثبت نظر" CTA on a completed booking detail lands here. */
+export const bookingReviewPath = (bookingId: number | string): string =>
+ `${ROUTES.BOOKINGS}/${bookingId}/review`;
+
+/** The E2 patient care-record viewer (f13) — reached by tapping a patient in the Patients tab. */
+export const patientRecordPath = (patientId: number | string): string =>
+ `${ROUTES.PATIENTS}/${patientId}/record`;
+
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
diff --git a/client/src/services/bookings/apis/mockApi.ts b/client/src/services/bookings/apis/mockApi.ts
index 03cb71f..be5edf4 100644
--- a/client/src/services/bookings/apis/mockApi.ts
+++ b/client/src/services/bookings/apis/mockApi.ts
@@ -263,6 +263,52 @@ function seed(): void {
},
],
},
+ // 5005 — a COMPLETED single-visit booking. There is otherwise no completed seed (checkOutVisit only
+ // reaches `completed` at runtime), so f13's leave-a-review flow needs this: the customer opens 5005 →
+ // review-eligible; its patient 905 + nurse 1 align with the reviews/records mocks for deep-links.
+ {
+ id: 5005,
+ bookingRequestId: 9005,
+ status: 'completed',
+ nurseId: NURSE_ID,
+ nurseName: NURSE_NAME,
+ patientId: 905,
+ patientName: 'بانو حسینی',
+ variantId: 15,
+ variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی — ویزیت', priceUnit: 'per_visit' }),
+ customerAddressId: 805,
+ addressSnapshotJson: JSON.stringify({ title: 'خانه', addressLine: 'تهران، خیابان ولیعصر', cityName: 'تهران', latitude: 35.72, longitude: 51.41 }),
+ grossPriceIrr: '3000000',
+ balinyaarCommissionIrr: '360000',
+ nursePayoutAmount: '2640000',
+ pspFeeAmount: '60000',
+ platformFeeRate: 0.12,
+ sessionCount: 1,
+ scheduledDate: isoDate(-2),
+ scheduledTimeStart: '09:00:00',
+ scheduledTimeEnd: '13:00:00',
+ confirmedAt: new Date(Date.now() - 4 * 86_400_000).toISOString(),
+ completedAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
+ cancelledAt: null,
+ cancelledBy: null,
+ cancellationReason: null,
+ cancellationPolicyCode: null,
+ cancellationRefundPercentage: null,
+ refundableAmountIrr: null,
+ disputeWindowEndsAt: new Date(Date.now() + 2 * 86_400_000).toISOString(),
+ createdAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
+ sessions: [
+ {
+ ...makeSession(70051, 1, -2, '2640000'),
+ status: 'completed',
+ payoutEligibleAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
+ evvStatus: 'completed',
+ checkInAt: new Date(Date.now() - 1 * 86_400_000 - 4 * 3_600_000).toISOString(),
+ checkOutAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
+ checkInAddressMatch: true,
+ },
+ ],
+ },
];
care[5001] = {
@@ -575,6 +621,16 @@ export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
return cloneBooking(findBooking(bookingId));
}
+/**
+ * Mock-only read for the reviews domain (f13): the booking (a safe clone) so the reviews mock can gate
+ * review-eligibility on a completed/closed booking and read `nurseId`/`patientId` for a submission. Throws
+ * `404` if unknown. One-way edge INTO bookings (the bookings mock never imports f13 back → no cycle). NOT
+ * part of the `BookingsApi` seam — only `services/reviews`' mock imports it.
+ */
+export function mockGetBookingForReview(bookingId: number): BookingDetailDto {
+ return cloneBooking(findBooking(bookingId));
+}
+
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
export interface CancelBookingSnapshot {
cancelledBy: string;
diff --git a/client/src/services/patientRecords/apis/clientApi.ts b/client/src/services/patientRecords/apis/clientApi.ts
new file mode 100644
index 0000000..d7e7c86
--- /dev/null
+++ b/client/src/services/patientRecords/apis/clientApi.ts
@@ -0,0 +1,106 @@
+import { clientFetch } from '@/lib/api/client';
+import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
+import type { PageParams } from '@/lib/api/types';
+import { RECORD_HISTORY_PAGE_SIZE } from '../constants';
+import type {
+ CreateVisitNoteRequest,
+ FamilyCareRecord,
+ PatientRecordsApi,
+ RecordAccess,
+ TaskResult,
+ UpdateFamilyRecordRequest,
+ VisitNote,
+ WriteVisitNoteResult,
+} from '../types';
+
+const API = '/api/v1';
+
+/** Wire `CareRecordDto` — the nurse-authored note (`body` decrypted after the access check). */
+interface CareRecordWire {
+ id: number;
+ patientId: number;
+ bookingId: number | null;
+ nurseProfileId: number;
+ nurseName: string | null;
+ body: string;
+ recordedAt: string;
+}
+
+function toVisitNote(w: CareRecordWire): VisitNote {
+ return {
+ id: w.id,
+ bookingId: w.bookingId,
+ nurseProfileId: w.nurseProfileId,
+ nurseDisplayName: w.nurseName,
+ body: w.body,
+ // The wire body carries only free text; the structured checklist is composed into it on write (see below).
+ taskResults: [],
+ recordedAt: w.recordedAt,
+ };
+}
+
+/**
+ * Folds the nurse's ticked task checklist into the free-text note body, because the wire
+ * `WriteCareRecordBody` has only `{ bookingId?, body }` — there is no structured task field (REQ-027 would
+ * add one). The mock keeps `taskResults` structured; the real path serialises them as a leading summary line.
+ */
+export function composeVisitNoteBody(body: string, taskResults: TaskResult[] | undefined): string {
+ const trimmed = body.trim();
+ if (!taskResults || taskResults.length === 0) return trimmed;
+ const summary = taskResults.map((t) => `${t.done ? '✓' : '✗'} ${t.label}`).join(' · ');
+ return trimmed ? `${summary}\n\n${trimmed}` : summary;
+}
+
+/**
+ * Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Two methods map **published**
+ * b14 routes:
+ * - `getPatientHistory` → `GET patients/{id}/care_records` (the patient-scoped, newest-first note history;
+ * a `403` from the envelope surfaces as an `ApiError` the E2 screen renders as access-denied).
+ * - `createVisitNote` → `POST patients/{id}/care_records` (a nurse appends one encrypted note).
+ *
+ * The family-record + access methods target contract gaps the frontend filed (**REQ-027**) — no wire
+ * endpoint exists — which is why the domain stays mock-primary (see `constants.ts`).
+ *
+ * NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
+ */
+export const patientRecordsClientApi: PatientRecordsApi = {
+ // REQ-027: proposed owner/nurse-scoped read of the family-owned record (no wire endpoint yet).
+ getFamilyRecord: async (patientId: number): Promise =>
+ unwrap(await clientFetch>(`${API}/patients/${patientId}/care_record`)),
+
+ // REQ-027: proposed access check. On the real path the 403 on the history read is the true access signal.
+ getRecordAccess: async (patientId: number): Promise =>
+ unwrap(await clientFetch>(`${API}/patients/${patientId}/record_access`)),
+
+ getPatientHistory: async (patientId: number, params: PageParams): Promise> => {
+ const query = new URLSearchParams();
+ query.set('page', String(params.page ?? 1));
+ query.set('pageSize', String(params.pageSize ?? RECORD_HISTORY_PAGE_SIZE));
+ const page = unwrap(
+ await clientFetch>>(
+ `${API}/patients/${patientId}/care_records?${query.toString()}`,
+ ),
+ );
+ return { ...page, items: page.items.map(toVisitNote) };
+ },
+
+ // REQ-027: proposed customer edit of the family record.
+ updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise =>
+ unwrap(
+ await clientFetch>(`${API}/patients/${patientId}/care_record`, {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ }),
+ ),
+
+ createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise =>
+ unwrap(
+ await clientFetch>(`${API}/patients/${patientId}/care_records`, {
+ method: 'POST',
+ body: JSON.stringify({
+ bookingId: body.bookingId ?? null,
+ body: composeVisitNoteBody(body.body, body.taskResults),
+ }),
+ }),
+ ),
+};
diff --git a/client/src/services/patientRecords/apis/index.ts b/client/src/services/patientRecords/apis/index.ts
new file mode 100644
index 0000000..4c718e8
--- /dev/null
+++ b/client/src/services/patientRecords/apis/index.ts
@@ -0,0 +1,13 @@
+import { USE_PATIENT_RECORDS_MOCK } from '../constants';
+import type { PatientRecordsApi } from '../types';
+import { patientRecordsClientApi } from './clientApi';
+import { patientRecordsMockApi } from './mockApi';
+
+/**
+ * The selected `PatientRecordsApi` implementation — the single seam the hooks import. Selection is by config
+ * (`USE_PATIENT_RECORDS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (the
+ * family-owned record + access check are REQ-027 gaps; the visit-note history/append are real b14).
+ */
+export const patientRecordsApi: PatientRecordsApi = USE_PATIENT_RECORDS_MOCK
+ ? patientRecordsMockApi
+ : patientRecordsClientApi;
diff --git a/client/src/services/patientRecords/apis/mockApi.ts b/client/src/services/patientRecords/apis/mockApi.ts
new file mode 100644
index 0000000..9232559
--- /dev/null
+++ b/client/src/services/patientRecords/apis/mockApi.ts
@@ -0,0 +1,195 @@
+import { sleep } from '@/utils';
+import { ApiError } from '@/lib/api/errors';
+import type { PageParams, Paginated } from '@/lib/api/types';
+import { MOCK_FOREIGN_PATIENT_ID } from '../constants';
+import type {
+ CreateVisitNoteRequest,
+ FamilyCareRecord,
+ Medication,
+ PatientRecordsApi,
+ RecordAccess,
+ RoutineItem,
+ UpdateFamilyRecordRequest,
+ VisitNote,
+ WriteVisitNoteResult,
+} from '../types';
+
+/**
+ * In-memory `PatientRecordsApi` — **the primary implementation this phase** (the nurse-authored visit-note
+ * history/append are real b14, but the family-owned medications/routine/tasks record + the access check are
+ * REQ-027 gaps — see `constants.ts`).
+ *
+ * The store is **patient-scoped** and lazily seeds a coherent default the first time any patient is read, so
+ * every E2 record viewer has content and every state is demoable:
+ * - a **default family record** (medications/routine/tasks the customer edits);
+ * - a **multi-nurse continuity history** (two prior notes from *different* nurses — proving the history
+ * persists across nurse changes; a nurse append prepends to the SAME patient's history);
+ * - a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID` → `canView: false` + a `403` on
+ * every read) so the non-leaking access-denied card is demoable.
+ *
+ * Money-free domain; clinical text is fixture data (never logged). Patient ids align with the f8 booking
+ * snapshots (patient 905 on the completed booking 5005) so the nurse note flow lands on a real record.
+ */
+
+const MOCK_LATENCY_MS = 300;
+
+/** The seeded nurse authoring a fresh note in the mock (the current session's nurse). */
+const MOCK_CURRENT_NURSE_ID = 1;
+const MOCK_CURRENT_NURSE_NAME = 'مریم رضایی';
+
+let nextNoteId = 8100;
+
+function isoDaysAgo(days: number): string {
+ return new Date(Date.now() - days * 86_400_000).toISOString();
+}
+
+/** The default family-owned record seeded for a patient on first access (the customer edits from here). */
+function defaultFamilyRecord(patientId: number): FamilyCareRecord {
+ return {
+ patientId,
+ medications: [
+ { id: 'm1', name: 'متفورمین ۵۰۰', dosage: '۱ قرص', frequency: 'روزی دو بار', timingNote: 'صبح و شب، بعد از غذا' },
+ { id: 'm2', name: 'لوزارتان ۲۵', dosage: '۱ قرص', frequency: 'روزی یک بار', timingNote: 'صبح' },
+ ],
+ routine: [
+ { id: 'r1', label: 'اندازهگیری فشار خون', timeOfDay: 'صبح', note: 'پیش از داروی فشار' },
+ { id: 'r2', label: 'پیادهروی کوتاه', timeOfDay: 'عصر', note: null },
+ ],
+ tasks: [
+ { id: 't1', label: 'دادن متفورمین', done: false },
+ { id: 't2', label: 'اندازهگیری فشار خون', done: false },
+ { id: 't3', label: 'پیادهروی کوتاه', done: false },
+ ],
+ };
+}
+
+/** A default continuity history seeded for a patient — two notes from DIFFERENT nurses (newest-first). */
+function defaultHistory(): VisitNote[] {
+ return [
+ {
+ id: nextNoteId++,
+ bookingId: null,
+ nurseProfileId: 2,
+ nurseDisplayName: 'سارا محمدی',
+ body: 'وضعیت بیمار پایدار بود؛ داروها طبق برنامه داده شد و فشار خون در محدودهٔ طبیعی بود.',
+ taskResults: [
+ { label: 'دادن متفورمین', done: true },
+ { label: 'اندازهگیری فشار خون', done: true },
+ ],
+ recordedAt: isoDaysAgo(2),
+ },
+ {
+ id: nextNoteId++,
+ bookingId: null,
+ nurseProfileId: MOCK_CURRENT_NURSE_ID,
+ nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
+ body: 'پیادهروی کوتاه انجام شد؛ اشتها خوب بود. توصیه به ادامهٔ روتین.',
+ taskResults: [{ label: 'پیادهروی کوتاه', done: true }],
+ recordedAt: isoDaysAgo(9),
+ },
+ ];
+}
+
+const familyRecords = new Map();
+const histories = new Map();
+
+function ensureFamilyRecord(patientId: number): FamilyCareRecord {
+ let record = familyRecords.get(patientId);
+ if (!record) {
+ record = defaultFamilyRecord(patientId);
+ familyRecords.set(patientId, record);
+ }
+ return record;
+}
+
+function ensureHistory(patientId: number): VisitNote[] {
+ let history = histories.get(patientId);
+ if (!history) {
+ history = defaultHistory();
+ histories.set(patientId, history);
+ }
+ return history;
+}
+
+/** Deep clone so a reader can't mutate the store by reference. */
+function cloneRecord(r: FamilyCareRecord): FamilyCareRecord {
+ return {
+ patientId: r.patientId,
+ medications: r.medications.map((m) => ({ ...m })),
+ routine: r.routine.map((x) => ({ ...x })),
+ tasks: r.tasks.map((t) => ({ ...t })),
+ };
+}
+
+function paginate(all: T[], params: PageParams): Paginated {
+ const page = Math.max(1, params.page ?? 1);
+ const pageSize = Math.max(1, params.pageSize ?? all.length);
+ const start = (page - 1) * pageSize;
+ return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
+}
+
+function assertAccess(patientId: number): void {
+ // The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
+ if (patientId === MOCK_FOREIGN_PATIENT_ID) {
+ throw new ApiError(403, 'No clinical access to this patient', 'no_access');
+ }
+}
+
+export const patientRecordsMockApi: PatientRecordsApi = {
+ getFamilyRecord: async (patientId: number): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ assertAccess(patientId);
+ return cloneRecord(ensureFamilyRecord(patientId));
+ },
+
+ getRecordAccess: async (patientId: number): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ if (patientId === MOCK_FOREIGN_PATIENT_ID) {
+ return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'no_access' };
+ }
+ // In the single-session mock, an authorized viewer can do everything; the SCREEN (customer vs nurse
+ // shell) decides which affordances to render — the nurse view never wires the edit path (append-only).
+ return { canView: true, canEdit: true, canAppendNote: true };
+ },
+
+ getPatientHistory: async (patientId: number, params: PageParams): Promise> => {
+ await sleep(MOCK_LATENCY_MS);
+ assertAccess(patientId);
+ const history = [...ensureHistory(patientId)].sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
+ return paginate(
+ history.map((n) => ({ ...n, taskResults: n.taskResults.map((t) => ({ ...t })) })),
+ params,
+ );
+ },
+
+ updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ assertAccess(patientId);
+ const record = ensureFamilyRecord(patientId);
+ if (body.medications) record.medications = body.medications.map((m: Medication) => ({ ...m }));
+ if (body.routine) record.routine = body.routine.map((r: RoutineItem) => ({ ...r }));
+ if (body.tasks) record.tasks = body.tasks.map((t) => ({ ...t }));
+ return cloneRecord(record);
+ },
+
+ createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ assertAccess(patientId);
+ const trimmed = body.body.trim();
+ if (!trimmed) throw new ApiError(400, 'Note body is required', 'empty_body');
+
+ const id = nextNoteId++;
+ const recordedAt = new Date().toISOString();
+ const note: VisitNote = {
+ id,
+ bookingId: body.bookingId ?? null,
+ nurseProfileId: MOCK_CURRENT_NURSE_ID,
+ nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
+ body: trimmed,
+ taskResults: (body.taskResults ?? []).map((t) => ({ ...t })),
+ recordedAt,
+ };
+ ensureHistory(patientId).unshift(note);
+ return { id, patientId, recordedAt };
+ },
+};
diff --git a/client/src/services/patientRecords/constants.ts b/client/src/services/patientRecords/constants.ts
new file mode 100644
index 0000000..682029a
--- /dev/null
+++ b/client/src/services/patientRecords/constants.ts
@@ -0,0 +1,36 @@
+/**
+ * When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
+ * `PatientRecordsApi` seam.
+ *
+ * **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records`
+ * GET/POST) — those two methods are real — but the **family-owned editable record** (medications/routine/
+ * tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has
+ * them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient,
+ * enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the
+ * history. Flip to `false` once REQ-027 lands — only `clientApi.ts`'s family-record/access methods flip; the
+ * history/append methods already map the real routes.
+ */
+export const USE_PATIENT_RECORDS_MOCK = true;
+
+/** Page size for the longitudinal visit-note history (api-conventions `pageSize`). */
+export const RECORD_HISTORY_PAGE_SIZE = 10;
+
+/**
+ * The family record + history move slowly (a customer edit / a per-visit note), so a modest `staleTime` means
+ * tab-switching never refetches; mutations invalidate the affected key. The access check is effectively
+ * static per session — keep it warm longer.
+ */
+export const FAMILY_RECORD_STALE_TIME = 60 * 1000;
+export const RECORD_HISTORY_STALE_TIME = 60 * 1000;
+export const RECORD_ACCESS_STALE_TIME = 5 * 60 * 1000;
+export const PATIENT_RECORDS_GC_TIME = 10 * 60 * 1000;
+
+/** Wire cap on a visit-note body (`WriteCareRecordBody.body` ≤ 8000). */
+export const VISIT_NOTE_MAX_LENGTH = 8000;
+
+/**
+ * A sentinel patient id the mock treats as **not owned** by the caller, so the E2 access-denied card is
+ * demoable by navigating to `/patients/8888/record`. On the real path this state comes from a `403` on the
+ * clinical read; there is no such thing as a "foreign patient id" on the wire.
+ */
+export const MOCK_FOREIGN_PATIENT_ID = 8888;
diff --git a/client/src/services/patientRecords/hooks/useCreateVisitNote.ts b/client/src/services/patientRecords/hooks/useCreateVisitNote.ts
new file mode 100644
index 0000000..becc946
--- /dev/null
+++ b/client/src/services/patientRecords/hooks/useCreateVisitNote.ts
@@ -0,0 +1,21 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { patientRecordsApi } from '../apis';
+import { recordKeys } from '../keys';
+import type { CreateVisitNoteRequest, WriteVisitNoteResult } from '../types';
+
+/**
+ * The **nurse** append of a visit note (append-only — the nurse never edits the family record). Bound to one
+ * patient. On success we invalidate the patient's **history** (every page) so the appended note appears in the
+ * longitudinal timeline at once. The mutation returns only the write result; the history refetch is the source
+ * of truth. Domain 4xx (`400` empty body, `403` no clinical access) surface to the caller's `onError`.
+ */
+export function useCreateVisitNote(patientId: number) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (body) => patientRecordsApi.createVisitNote(patientId, body),
+ onSuccess: () => {
+ // Prefix invalidation: [...histories(), patientId] matches every page of this patient's history.
+ queryClient.invalidateQueries({ queryKey: [...recordKeys.histories(), patientId] });
+ },
+ });
+}
diff --git a/client/src/services/patientRecords/hooks/usePatientCareRecord.ts b/client/src/services/patientRecords/hooks/usePatientCareRecord.ts
new file mode 100644
index 0000000..f36f1e8
--- /dev/null
+++ b/client/src/services/patientRecords/hooks/usePatientCareRecord.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { patientRecordsApi } from '../apis';
+import { recordKeys } from '../keys';
+import { FAMILY_RECORD_STALE_TIME, PATIENT_RECORDS_GC_TIME } from '../constants';
+
+/**
+ * The family-owned, patient-scoped editable record (medications/routine/tasks). Keyed per patient; a customer
+ * edit invalidates this key. A `403` (access-denied) surfaces as the query's error — the E2 screen gates on
+ * `useRecordAccess` first, so this normally only runs for an authorized viewer.
+ */
+export function usePatientCareRecord(patientId: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: recordKeys.patient(patientId),
+ queryFn: () => patientRecordsApi.getFamilyRecord(patientId),
+ enabled: (options?.enabled ?? true) && patientId > 0,
+ staleTime: FAMILY_RECORD_STALE_TIME,
+ gcTime: PATIENT_RECORDS_GC_TIME,
+ });
+}
diff --git a/client/src/services/patientRecords/hooks/usePatientHistory.ts b/client/src/services/patientRecords/hooks/usePatientHistory.ts
new file mode 100644
index 0000000..f934239
--- /dev/null
+++ b/client/src/services/patientRecords/hooks/usePatientHistory.ts
@@ -0,0 +1,21 @@
+import { keepPreviousData, useQuery } from '@tanstack/react-query';
+import { patientRecordsApi } from '../apis';
+import { recordKeys } from '../keys';
+import { PATIENT_RECORDS_GC_TIME, RECORD_HISTORY_PAGE_SIZE, RECORD_HISTORY_STALE_TIME } from '../constants';
+
+/**
+ * The patient-scoped longitudinal visit-note history (newest-first, paged) — REAL b14 `care_records` GET.
+ * Read-only; it **persists across nurse changes** (keyed to the patient, not a booking). The page is part of
+ * the query key so paging never refetches a page already in cache; `keepPreviousData` avoids an empty flash.
+ * A nurse note append invalidates these keys so the new note appears at once.
+ */
+export function usePatientHistory(patientId: number, page: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: recordKeys.history(patientId, page),
+ queryFn: () => patientRecordsApi.getPatientHistory(patientId, { page, pageSize: RECORD_HISTORY_PAGE_SIZE }),
+ enabled: (options?.enabled ?? true) && patientId > 0,
+ staleTime: RECORD_HISTORY_STALE_TIME,
+ gcTime: PATIENT_RECORDS_GC_TIME,
+ placeholderData: keepPreviousData,
+ });
+}
diff --git a/client/src/services/patientRecords/hooks/useRecordAccess.ts b/client/src/services/patientRecords/hooks/useRecordAccess.ts
new file mode 100644
index 0000000..a523c7e
--- /dev/null
+++ b/client/src/services/patientRecords/hooks/useRecordAccess.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { patientRecordsApi } from '../apis';
+import { recordKeys } from '../keys';
+import { PATIENT_RECORDS_GC_TIME, RECORD_ACCESS_STALE_TIME } from '../constants';
+
+/**
+ * Who may view/edit/append this patient's record. The E2 viewer gates on `canView` **before** fetching the
+ * record/history, so an unauthorized viewer never pulls clinical data (the two-stage-disclosure discipline —
+ * the access decision lives here, not in the presentational card). Effectively static per session.
+ */
+export function useRecordAccess(patientId: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: recordKeys.access(patientId),
+ queryFn: () => patientRecordsApi.getRecordAccess(patientId),
+ enabled: (options?.enabled ?? true) && patientId > 0,
+ staleTime: RECORD_ACCESS_STALE_TIME,
+ gcTime: PATIENT_RECORDS_GC_TIME,
+ });
+}
diff --git a/client/src/services/patientRecords/hooks/useUpdateCareRecord.ts b/client/src/services/patientRecords/hooks/useUpdateCareRecord.ts
new file mode 100644
index 0000000..cd02ec2
--- /dev/null
+++ b/client/src/services/patientRecords/hooks/useUpdateCareRecord.ts
@@ -0,0 +1,20 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { patientRecordsApi } from '../apis';
+import { recordKeys } from '../keys';
+import type { FamilyCareRecord, UpdateFamilyRecordRequest } from '../types';
+
+/**
+ * The **customer** edit of the family-owned record (medications/routine/tasks). Bound to one patient. On
+ * success we write the returned record straight into the cache (`setQueryData`) so the edit shows without a
+ * refetch flash. This hook is **customer-only** — it must **never** be wired into the nurse view (the nurse is
+ * append-only).
+ */
+export function useUpdateCareRecord(patientId: number) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (body) => patientRecordsApi.updateFamilyRecord(patientId, body),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(recordKeys.patient(patientId), updated);
+ },
+ });
+}
diff --git a/client/src/services/patientRecords/index.ts b/client/src/services/patientRecords/index.ts
new file mode 100644
index 0000000..35680a5
--- /dev/null
+++ b/client/src/services/patientRecords/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Patient-records domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
+ * types/keys/apis directly from their files when needed.
+ *
+ * NB `useUpdateCareRecord` is **customer-only** and `useCreateVisitNote` is **nurse-only** — the nurse view
+ * must never import the former (append-only is a hard boundary, not a hidden button).
+ */
+export { usePatientCareRecord } from './hooks/usePatientCareRecord';
+export { useRecordAccess } from './hooks/useRecordAccess';
+export { usePatientHistory } from './hooks/usePatientHistory';
+export { useUpdateCareRecord } from './hooks/useUpdateCareRecord';
+export { useCreateVisitNote } from './hooks/useCreateVisitNote';
diff --git a/client/src/services/patientRecords/keys.ts b/client/src/services/patientRecords/keys.ts
new file mode 100644
index 0000000..103801f
--- /dev/null
+++ b/client/src/services/patientRecords/keys.ts
@@ -0,0 +1,20 @@
+/**
+ * React Query key factory for the patient-records domain (hierarchical, per the `services/{domain}` pattern).
+ *
+ * Every key is **patient-scoped** (the record is keyed to the patient, not a booking). The family record,
+ * the access check, and each history page key independently so revisiting a tab never refetches. A customer
+ * edit invalidates `patient(patientId)`; a nurse note append invalidates the patient's `history` **only** —
+ * the append is append-only and does not mutate the family record, so `patient` stays validly cached.
+ */
+export const recordKeys = {
+ all: ['patient_records'] as const,
+
+ patients: () => [...recordKeys.all, 'patient'] as const,
+ /** The family-owned medications/routine/tasks record. */
+ patient: (patientId: number) => [...recordKeys.patients(), patientId] as const,
+
+ access: (patientId: number) => [...recordKeys.all, 'access', patientId] as const,
+
+ histories: () => [...recordKeys.all, 'history'] as const,
+ history: (patientId: number, page: number) => [...recordKeys.histories(), patientId, page] as const,
+};
diff --git a/client/src/services/patientRecords/types.ts b/client/src/services/patientRecords/types.ts
new file mode 100644
index 0000000..2b0aa9f
--- /dev/null
+++ b/client/src/services/patientRecords/types.ts
@@ -0,0 +1,145 @@
+import type { PageParams, Paginated } from '@/lib/api/types';
+
+/**
+ * Patient care-records domain — the **continuity-of-care** surface (b14). Two very different things share
+ * one screen (the E2 record viewer):
+ *
+ * 1. **The nurse-authored, patient-scoped visit-note history** (سوابق) — **REAL** (b14 `care_records`
+ * GET/POST). Encrypted at rest, returned decrypted only after the clinical-access check passes; it is
+ * **patient-scoped, not booking-scoped**, so a new nurse taking over reads the whole history. A nurse
+ * with a qualifying booking may **append** a note; nobody edits it.
+ * 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend
+ * exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it;
+ * it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`).
+ *
+ * Load-bearing rules (contract + phase §5):
+ * - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
+ * persists across nurse changes (keyed to the patient, not the booking).
+ * - **Nurse is append-only.** The nurse view exposes the task checklist + a note composer + the read-only
+ * history — it must **never** wire `updateFamilyRecord` or any medication/routine/task editing.
+ * - **Strict access.** Owning customer / nurse with a confirmed booking / admin only. A `403` on a read →
+ * render a clear, non-leaking access-denied card (never partial clinical data).
+ * - **Clinical fields are sensitive** — never logged, never in `localStorage`, never in a query string.
+ *
+ * Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` + swagger); the
+ * family-record shapes are the client's own (REQ-027).
+ */
+
+/** The four tabs of the E2 record viewer (client display model). */
+export type CareRecordTab = 'medications' | 'routine' | 'history' | 'tasks';
+export const CARE_RECORD_TABS: readonly CareRecordTab[] = ['medications', 'routine', 'history', 'tasks'] as const;
+
+// ── Family-owned editable record (REQ-027 — customer-maintained, no backend) ──────────────────────────────
+
+/** A medication the family tracks. `id` is a stable client id (the record has no server identity yet). */
+export interface Medication {
+ id: string;
+ name: string;
+ dosage: string | null;
+ frequency: string;
+ timingNote: string | null;
+}
+
+/** A daily-routine item the family tracks (e.g. "measure blood pressure — morning"). */
+export interface RoutineItem {
+ id: string;
+ label: string;
+ timeOfDay: string | null;
+ note: string | null;
+}
+
+/** A care task (the checklist the nurse ticks during a visit; the family authors the list). */
+export interface CareTask {
+ id: string;
+ label: string;
+ done: boolean;
+}
+
+/** The family-owned, patient-scoped editable record (REQ-027). */
+export interface FamilyCareRecord {
+ patientId: number;
+ medications: Medication[];
+ routine: RoutineItem[];
+ tasks: CareTask[];
+}
+
+// ── Nurse-authored visit-note history (REAL — b14 care_records) ───────────────────────────────────────────
+
+/** A structured "task X done/not-done" line the nurse ticked — **client-only** (the wire body is free text). */
+export interface TaskResult {
+ label: string;
+ done: boolean;
+}
+
+/**
+ * One nurse-authored visit note = one `CareRecordDto` (patient-scoped, newest-first). Read-only/append-only
+ * from the client. `taskResults` is a client-only structured summary of the checklist the nurse ticked — the
+ * wire `body` carries only free text, so on the real path the checklist is folded into `body`.
+ */
+export interface VisitNote {
+ id: number;
+ bookingId: number | null;
+ nurseProfileId: number;
+ nurseDisplayName: string | null;
+ body: string;
+ taskResults: TaskResult[];
+ /** UTC ISO-8601 — Shamsi display is the client's job. */
+ recordedAt: string;
+}
+
+// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ─────────────────
+
+export type RecordAccessDeniedReason = 'no_access' | 'not_found';
+
+/**
+ * Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a
+ * nurse with a qualifying booking only. A denied read surfaces `canView: false` + a `deniedReason`.
+ */
+export interface RecordAccess {
+ canView: boolean;
+ canEdit: boolean;
+ canAppendNote: boolean;
+ deniedReason?: RecordAccessDeniedReason;
+}
+
+/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */
+export interface UpdateFamilyRecordRequest {
+ medications?: Medication[];
+ routine?: RoutineItem[];
+ tasks?: CareTask[];
+}
+
+/**
+ * The nurse append body. Maps to the wire `WriteCareRecordBody` (`{ bookingId?, body }`): on the real path
+ * `taskResults` is composed into `body` (the wire has no structured task field); the mock keeps it structured.
+ */
+export interface CreateVisitNoteRequest {
+ bookingId?: number | null;
+ body: string;
+ taskResults?: TaskResult[];
+}
+
+/** `WriteCareRecordResult` — the append result. */
+export interface WriteVisitNoteResult {
+ id: number;
+ patientId: number;
+ recordedAt: string;
+}
+
+/**
+ * The patient-records API seam — the real HTTP client and the in-memory mock both implement this; selection
+ * is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. `getPatientHistory` +
+ * `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked).
+ */
+export interface PatientRecordsApi {
+ /** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */
+ getFamilyRecord(patientId: number): Promise;
+ /** REQ-027 — who may view/edit/append for this patient (derived from the 403 on a read + the caller role). */
+ getRecordAccess(patientId: number): Promise;
+ /** REAL — the patient-scoped longitudinal visit-note history, newest-first, paged. */
+ getPatientHistory(patientId: number, params: PageParams): Promise>;
+ /** REQ-027 — the customer replaces sections of the family record. */
+ updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise;
+ /** REAL — a nurse appends a visit note (append-only; never edits the record). */
+ createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise;
+}
diff --git a/client/src/services/patients/hooks/usePatient.ts b/client/src/services/patients/hooks/usePatient.ts
new file mode 100644
index 0000000..9f6460d
--- /dev/null
+++ b/client/src/services/patients/hooks/usePatient.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { patientsApi } from '../apis';
+import { patientKeys } from '../keys';
+import { PATIENTS_STALE_TIME } from '../constants';
+
+/**
+ * A single patient by id — the identity header for the E2 record viewer (name, age/gender, conditions).
+ * Keyed on `patientKeys.detail(id)` so it shares/warms the same cache the list primes. A missing/cross-tenant
+ * id `404`s (surfaced as the query error); the caller renders a not-found state. `enabled` lets the E2 viewer
+ * defer the fetch until the clinical-access check passes.
+ */
+export function usePatient(id: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: patientKeys.detail(id),
+ queryFn: () => patientsApi.get(id),
+ enabled: (options?.enabled ?? true) && id > 0,
+ staleTime: PATIENTS_STALE_TIME,
+ });
+}
diff --git a/client/src/services/patients/index.ts b/client/src/services/patients/index.ts
index 46862b0..dea314c 100644
--- a/client/src/services/patients/index.ts
+++ b/client/src/services/patients/index.ts
@@ -1,4 +1,5 @@
export { usePatients } from './hooks/usePatients';
+export { usePatient } from './hooks/usePatient';
export { useCreatePatient } from './hooks/useCreatePatient';
export { useUpdatePatient } from './hooks/useUpdatePatient';
export { useArchivePatient } from './hooks/useArchivePatient';
diff --git a/client/src/services/reviews/apis/clientApi.ts b/client/src/services/reviews/apis/clientApi.ts
new file mode 100644
index 0000000..ef0e872
--- /dev/null
+++ b/client/src/services/reviews/apis/clientApi.ts
@@ -0,0 +1,66 @@
+import { clientFetch } from '@/lib/api/client';
+import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
+import type { PageParams } from '@/lib/api/types';
+import { REVIEWS_PAGE_SIZE } from '../constants';
+import type {
+ CreateReviewRequest,
+ MyReviewState,
+ NurseReviews,
+ ReviewEligibility,
+ ReviewListItem,
+ ReviewsApi,
+ SubmitReviewResult,
+} from '../types';
+
+const API = '/api/v1';
+
+/** Wire `NurseReviewsResult` — `reviews` is a `PagedResult` (camelCase, per api-conventions). */
+interface NurseReviewsWire {
+ aggregate: { averageRating: number; publishedCount: number };
+ reviews: Paginated;
+}
+
+/**
+ * Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
+ * swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes:
+ * - `getNurseReviews` → `GET nurses/{id}/reviews` (aggregate + published page; server filters to published).
+ * - `createReview` → `POST bookings/{id}/review` (the one review per completed booking; `409` if reviewed).
+ *
+ * The other two target contract gaps the frontend filed (**REQ-026**), which is why the domain stays
+ * mock-primary (see `constants.ts`):
+ * - `getReviewEligibility` → whether this booking can still be reviewed (no wire read; proposed slug below).
+ * - `getMyReviewForBooking` → the caller's own review + its moderation state (no wire read; proposed slug).
+ *
+ * NOT the primary implementation this phase (`USE_REVIEWS_MOCK = true`). `clientFetch` returns the raw
+ * envelope so we `unwrap()`; ids come from the route; list params are camelCase (`page`/`pageSize`).
+ */
+export const reviewsClientApi: ReviewsApi = {
+ getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise => {
+ const query = new URLSearchParams();
+ query.set('page', String(params.page ?? 1));
+ query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE));
+ const wire = unwrap(
+ await clientFetch>(
+ `${API}/nurses/${nurseProfileId}/reviews?${query.toString()}`,
+ ),
+ );
+ return { aggregate: wire.aggregate, reviews: wire.reviews };
+ },
+
+ // REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while
+ // the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip.
+ getReviewEligibility: async (bookingId: number): Promise =>
+ unwrap(await clientFetch>(`${API}/bookings/${bookingId}/review_eligibility`)),
+
+ // REQ-026: proposed owner-scoped read of the caller's own review for this booking.
+ getMyReviewForBooking: async (bookingId: number): Promise =>
+ unwrap(await clientFetch>(`${API}/bookings/${bookingId}/my_review`)),
+
+ createReview: async (bookingId: number, body: CreateReviewRequest): Promise =>
+ unwrap(
+ await clientFetch>(`${API}/bookings/${bookingId}/review`, {
+ method: 'POST',
+ body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
+ }),
+ ),
+};
diff --git a/client/src/services/reviews/apis/index.ts b/client/src/services/reviews/apis/index.ts
new file mode 100644
index 0000000..a21d55b
--- /dev/null
+++ b/client/src/services/reviews/apis/index.ts
@@ -0,0 +1,11 @@
+import { USE_REVIEWS_MOCK } from '../constants';
+import type { ReviewsApi } from '../types';
+import { reviewsClientApi } from './clientApi';
+import { reviewsMockApi } from './mockApi';
+
+/**
+ * The selected `ReviewsApi` implementation — the single seam the hooks import. Selection is by config
+ * (`USE_REVIEWS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-026 gaps +
+ * admin-only moderation).
+ */
+export const reviewsApi: ReviewsApi = USE_REVIEWS_MOCK ? reviewsMockApi : reviewsClientApi;
diff --git a/client/src/services/reviews/apis/mockApi.ts b/client/src/services/reviews/apis/mockApi.ts
new file mode 100644
index 0000000..7908837
--- /dev/null
+++ b/client/src/services/reviews/apis/mockApi.ts
@@ -0,0 +1,181 @@
+import { sleep } from '@/utils';
+import { ApiError } from '@/lib/api/errors';
+import type { PageParams, Paginated } from '@/lib/api/types';
+import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi';
+import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants';
+import type {
+ CreateReviewRequest,
+ ModerationStatus,
+ MyReviewState,
+ NurseReviews,
+ ReviewEligibility,
+ ReviewListItem,
+ ReviewsApi,
+ SubmitReviewResult,
+} from '../types';
+
+/**
+ * In-memory `ReviewsApi` — **the primary implementation this phase** (b14 serves submit + the public list,
+ * but review-eligibility and my-review-for-booking are REQ-026 gaps and moderation is admin-only/f15 — see
+ * `constants.ts`).
+ *
+ * It is engineered to demo the whole trust loop end-to-end:
+ * - **Published seed** — a per-nurse published-review list (nurse 1 has 7 so the profile tab paginates;
+ * nurses 5/6 have none → the empty state). The aggregate is recomputed **from the published list**, never
+ * stored, so hiding/publishing a review re-derives the average (the server's job, mirrored here).
+ * - **Eligibility** reads a single booking from the shared **f8 bookings store** (`mockGetBookingForReview`)
+ * — a booking is reviewable only when `completed`/`closed` AND not already reviewed (the 1:1 rule).
+ * - **Submission tracking** — `createReview` records the customer's review as `pending_moderation` (it does
+ * **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking`
+ * returns the persistent "under review" state.
+ * - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in for the deferred (f15) admin
+ * moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse
+ * profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen.
+ *
+ * Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a
+ * completed booking deep-links correctly.
+ */
+
+const MOCK_LATENCY_MS = 300;
+
+/** A submitted review the mock is tracking (client-side stand-in for the missing my-review read, REQ-026). */
+interface SubmittedReview {
+ id: number;
+ bookingId: number;
+ nurseProfileId: number;
+ rating: number;
+ body: string | null;
+ tagCodes: string[];
+ status: ModerationStatus;
+ createdAt: string;
+}
+
+/** ISO instant `days` in the past — seeded review timestamps (rendered Shamsi client-side). */
+function isoDaysAgo(days: number): string {
+ return new Date(Date.now() - days * 86_400_000).toISOString();
+}
+
+let nextReviewId = 9100;
+
+// ── Published reviews per nurse (the public profile tab reads these) ──────────────────────────────────────
+const PUBLISHED: Record = {
+ 1: [
+ { id: 9001, rating: 5, body: 'بسیار دقیق و مهربان بود؛ سر وقت رسید و همهچیز را توضیح داد.', tagCodes: ['punctual', 'kind', 'professional'], createdAt: isoDaysAgo(3) },
+ { id: 9002, rating: 5, body: 'مراقبت حرفهای و تمیز. خیالمان راحت بود.', tagCodes: ['professional', 'clean'], createdAt: isoDaysAgo(9) },
+ { id: 9003, rating: 4, body: 'ارتباط خوبی با بیمار برقرار کرد.', tagCodes: ['communicative', 'kind'], createdAt: isoDaysAgo(14) },
+ { id: 9004, rating: 5, body: 'واقعاً منظم و قابلاعتماد.', tagCodes: ['punctual', 'professional'], createdAt: isoDaysAgo(21) },
+ { id: 9005, rating: 4, body: null, tagCodes: ['clean'], createdAt: isoDaysAgo(28) },
+ { id: 9006, rating: 5, body: 'از پرستاریاش بسیار راضی بودیم.', tagCodes: ['kind', 'communicative'], createdAt: isoDaysAgo(35) },
+ { id: 9007, rating: 3, body: 'خوب بود ولی کمی دیر رسید.', tagCodes: ['professional'], createdAt: isoDaysAgo(44) },
+ ],
+ 2: [
+ { id: 9021, rating: 5, body: 'با حوصله و مسلط.', tagCodes: ['professional', 'kind'], createdAt: isoDaysAgo(6) },
+ { id: 9022, rating: 4, body: 'مراقبت خوبی داشت.', tagCodes: ['communicative'], createdAt: isoDaysAgo(18) },
+ ],
+ 3: [{ id: 9031, rating: 5, body: 'عالی بود، پیشنهاد میکنم.', tagCodes: ['punctual', 'clean', 'kind'], createdAt: isoDaysAgo(11) }],
+ 4: [
+ { id: 9041, rating: 4, body: 'قابل اعتماد و آرام.', tagCodes: ['kind'], createdAt: isoDaysAgo(4) },
+ { id: 9042, rating: 5, body: 'خیلی حرفهای برخورد کرد.', tagCodes: ['professional', 'communicative'], createdAt: isoDaysAgo(20) },
+ ],
+ // nurses 5 & 6: no published reviews → the empty state on their profile tab.
+};
+
+const submissions = new Map();
+
+/** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */
+function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } {
+ const list = PUBLISHED[nurseProfileId] ?? [];
+ if (list.length === 0) return { averageRating: 0, publishedCount: 0 };
+ const sum = list.reduce((acc, r) => acc + r.rating, 0);
+ return { averageRating: Math.round((sum / list.length) * 100) / 100, publishedCount: list.length };
+}
+
+function paginate(all: T[], params: PageParams): Paginated {
+ const page = Math.max(1, params.page ?? 1);
+ const pageSize = Math.max(1, params.pageSize ?? all.length);
+ const start = (page - 1) * pageSize;
+ return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
+}
+
+/** Is this booking (from the shared f8 store) in a review-eligible terminal state? */
+function isReviewableStatus(status: string): boolean {
+ return status === 'completed' || status === 'closed';
+}
+
+export const reviewsMockApi: ReviewsApi = {
+ getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ // Newest-first, published only — the mock never returns a submission (it is pending_moderation).
+ const list = [...(PUBLISHED[nurseProfileId] ?? [])].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+ return { aggregate: aggregateFor(nurseProfileId), reviews: paginate(list, params) };
+ },
+
+ getReviewEligibility: async (bookingId: number): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ if (submissions.has(bookingId)) return { canReview: false, reason: 'already_reviewed' };
+ let booking;
+ try {
+ booking = mockGetBookingForReview(bookingId);
+ } catch {
+ return { canReview: false, reason: 'not_found' };
+ }
+ if (!isReviewableStatus(booking.status)) return { canReview: false, reason: 'not_completed' };
+ return { canReview: true };
+ },
+
+ getMyReviewForBooking: async (bookingId: number): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ const sub = submissions.get(bookingId);
+ if (!sub) return { status: 'none', rating: null, body: null, tagCodes: [], createdAt: null };
+ return { status: sub.status, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt };
+ },
+
+ createReview: async (bookingId: number, body: CreateReviewRequest): Promise => {
+ await sleep(MOCK_LATENCY_MS);
+ if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) {
+ throw new ApiError(400, 'Rating must be 1–5', 'rating_out_of_range');
+ }
+ // 1:1 — a second review for the same booking is a 409 (contract).
+ if (submissions.has(bookingId)) throw new ApiError(409, 'Booking already reviewed', 'already_reviewed');
+
+ // Reviews are only for completed/closed bookings — read the shared f8 store (404 → not found).
+ const booking = mockGetBookingForReview(bookingId);
+ if (!isReviewableStatus(booking.status)) {
+ throw new ApiError(409, 'Booking is not completed', 'booking_not_completed');
+ }
+
+ const id = nextReviewId++;
+ const submission: SubmittedReview = {
+ id,
+ bookingId,
+ nurseProfileId: booking.nurseId,
+ rating: body.rating,
+ body: body.body?.trim() || null,
+ tagCodes: body.tagCodes ?? [],
+ // The AI pre-screen keeps clean text pending; it is NOT public until an admin publishes it (f15).
+ status: 'pending_moderation',
+ createdAt: new Date().toISOString(),
+ };
+ submissions.set(bookingId, submission);
+
+ return {
+ id,
+ moderationStatus: 'pending_moderation',
+ lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT,
+ };
+ },
+};
+
+/**
+ * DEV-ONLY: publish a submitted review, standing in for the deferred (f15) admin moderation queue. Moves the
+ * customer's `pending_moderation` submission to `published` and prepends it to the nurse's public list so a
+ * human can watch it appear on the profile (aggregate + count updating on the next fetch). Not wired into any
+ * customer/nurse screen — call it from the console (or a later admin surface). No-op if unknown.
+ */
+export function __mockPublishSubmittedReview(bookingId: number): void {
+ const sub = submissions.get(bookingId);
+ if (!sub) return;
+ sub.status = 'published';
+ const list = PUBLISHED[sub.nurseProfileId] ?? (PUBLISHED[sub.nurseProfileId] = []);
+ list.unshift({ id: sub.id, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt });
+}
diff --git a/client/src/services/reviews/constants.ts b/client/src/services/reviews/constants.ts
new file mode 100644
index 0000000..e2516e5
--- /dev/null
+++ b/client/src/services/reviews/constants.ts
@@ -0,0 +1,33 @@
+/**
+ * When true, the reviews domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `ReviewsApi`
+ * seam.
+ *
+ * **Mock is primary this phase.** b14 serves the review **submit**, the public **nurse reviews** page, and
+ * the tag rollup — but there is **no** review-eligibility read and **no** my-review-for-booking read
+ * (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is admin-only (f15).
+ * The mock reads the shared **f8 bookings store** to gate eligibility on a completed booking, tracks the
+ * customer's submission so the "under review" state persists, seeds a small published-review list per nurse
+ * for the profile tab, and exposes a dev-only `__mockPublishSubmittedReview` so a human can watch a submitted
+ * review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
+ * hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
+ */
+export const USE_REVIEWS_MOCK = true;
+
+/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
+export const REVIEWS_PAGE_SIZE = 5;
+
+/**
+ * The published-review list moves slowly (a moderation transition is a rare admin action) — a generous
+ * `staleTime` means revisiting a profile never needlessly refetches. Eligibility/my-review are per-booking
+ * and are **invalidated on submit**, so their staleness is short (a submit must flip the CTA immediately).
+ */
+export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000;
+export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000;
+export const REVIEWS_GC_TIME = 10 * 60 * 1000;
+
+/**
+ * The low-rating support-alert threshold (server config, default ≤ 2). The mock echoes it on
+ * `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** — it exists only so the mock's
+ * result shape matches the wire.
+ */
+export const MIN_RATING_FOR_SUPPORT_ALERT = 2;
diff --git a/client/src/services/reviews/hooks/useCreateReview.ts b/client/src/services/reviews/hooks/useCreateReview.ts
new file mode 100644
index 0000000..b1f1f50
--- /dev/null
+++ b/client/src/services/reviews/hooks/useCreateReview.ts
@@ -0,0 +1,27 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { reviewsApi } from '../apis';
+import { reviewKeys } from '../keys';
+import type { CreateReviewRequest, SubmitReviewResult } from '../types';
+
+interface CreateReviewVars {
+ bookingId: number;
+ body: CreateReviewRequest;
+}
+
+/**
+ * Submit the one review for a completed booking. On success we invalidate **only** the booking's
+ * `eligibility` + `myReviewForBooking` keys — so the CTA flips to the "under review" state at once — and
+ * deliberately do **NOT** touch any public nurse-reviews list or aggregate: the new review is
+ * `pending_moderation` and must never appear publicly until an admin publishes it. Domain 4xx (`409`
+ * already-reviewed, `400` bad rating) surface to the caller's `onError`, which keeps the draft.
+ */
+export function useCreateReview() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ bookingId, body }) => reviewsApi.createReview(bookingId, body),
+ onSuccess: (_result, { bookingId }) => {
+ queryClient.invalidateQueries({ queryKey: reviewKeys.eligibility(bookingId) });
+ queryClient.invalidateQueries({ queryKey: reviewKeys.myReviewForBooking(bookingId) });
+ },
+ });
+}
diff --git a/client/src/services/reviews/hooks/useMyReviewForBooking.ts b/client/src/services/reviews/hooks/useMyReviewForBooking.ts
new file mode 100644
index 0000000..d6b3262
--- /dev/null
+++ b/client/src/services/reviews/hooks/useMyReviewForBooking.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { reviewsApi } from '../apis';
+import { reviewKeys } from '../keys';
+import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
+
+/**
+ * The customer's **own** review for this booking + its current moderation state (`none` when not yet
+ * reviewed). Drives the persistent "under review" / "published" state on the leave-a-review CTA so a returning
+ * customer never sees a second form. Keyed per booking; **invalidated on submit**.
+ */
+export function useMyReviewForBooking(bookingId: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: reviewKeys.myReviewForBooking(bookingId),
+ queryFn: () => reviewsApi.getMyReviewForBooking(bookingId),
+ enabled: (options?.enabled ?? true) && bookingId > 0,
+ staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
+ gcTime: REVIEWS_GC_TIME,
+ });
+}
diff --git a/client/src/services/reviews/hooks/useNurseReviews.ts b/client/src/services/reviews/hooks/useNurseReviews.ts
new file mode 100644
index 0000000..4f6a09b
--- /dev/null
+++ b/client/src/services/reviews/hooks/useNurseReviews.ts
@@ -0,0 +1,27 @@
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { reviewsApi } from '../apis';
+import { reviewKeys } from '../keys';
+import { NURSE_REVIEWS_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants';
+
+/**
+ * The nurse's **public** reviews list — the rating aggregate + an infinite, load-more list of **published**
+ * reviews. Infinite paging keeps every loaded page in one cache entry keyed on the nurse id, so revisiting a
+ * profile never refetches and "load more" appends without a setState-in-effect. The aggregate rides on every
+ * page (same value) — read it off the first page. Never returns/renders non-published content (server
+ * invariant); a submitted `pending_moderation` review is **never** injected here.
+ */
+export function useNurseReviews(nurseProfileId: number | undefined) {
+ return useInfiniteQuery({
+ queryKey: reviewKeys.nurse(nurseProfileId ?? -1),
+ queryFn: ({ pageParam }) =>
+ reviewsApi.getNurseReviews(nurseProfileId as number, { page: pageParam, pageSize: REVIEWS_PAGE_SIZE }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ const { page, pageSize, total } = lastPage.reviews;
+ return page * pageSize < total ? page + 1 : undefined;
+ },
+ enabled: nurseProfileId != null,
+ staleTime: NURSE_REVIEWS_STALE_TIME,
+ gcTime: REVIEWS_GC_TIME,
+ });
+}
diff --git a/client/src/services/reviews/hooks/useReviewEligibility.ts b/client/src/services/reviews/hooks/useReviewEligibility.ts
new file mode 100644
index 0000000..876ac60
--- /dev/null
+++ b/client/src/services/reviews/hooks/useReviewEligibility.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { reviewsApi } from '../apis';
+import { reviewKeys } from '../keys';
+import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
+
+/**
+ * Whether *this* booking can still be reviewed (completed/closed AND not already reviewed). Keyed per booking;
+ * short `staleTime` and **invalidated on submit** so the leave-a-review CTA flips to the "under review" state
+ * immediately. `enabled` lets the caller defer until it holds a real booking id.
+ */
+export function useReviewEligibility(bookingId: number, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: reviewKeys.eligibility(bookingId),
+ queryFn: () => reviewsApi.getReviewEligibility(bookingId),
+ enabled: (options?.enabled ?? true) && bookingId > 0,
+ staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
+ gcTime: REVIEWS_GC_TIME,
+ });
+}
diff --git a/client/src/services/reviews/index.ts b/client/src/services/reviews/index.ts
new file mode 100644
index 0000000..4aaf9be
--- /dev/null
+++ b/client/src/services/reviews/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Reviews domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
+ * types/keys/apis directly from their files when needed.
+ */
+export { useNurseReviews } from './hooks/useNurseReviews';
+export { useReviewEligibility } from './hooks/useReviewEligibility';
+export { useMyReviewForBooking } from './hooks/useMyReviewForBooking';
+export { useCreateReview } from './hooks/useCreateReview';
diff --git a/client/src/services/reviews/keys.ts b/client/src/services/reviews/keys.ts
new file mode 100644
index 0000000..b610cee
--- /dev/null
+++ b/client/src/services/reviews/keys.ts
@@ -0,0 +1,18 @@
+/**
+ * React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern).
+ *
+ * The **nurse id + page** key the public list so paging/revisiting a profile never refetches data already in
+ * cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state
+ * independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the
+ * booking — the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public).
+ */
+export const reviewKeys = {
+ all: ['reviews'] as const,
+
+ nurseLists: () => [...reviewKeys.all, 'nurse'] as const,
+ /** The public reviews list for a nurse. Pages are managed by `useInfiniteQuery`, so no page in the key. */
+ nurse: (nurseProfileId: number) => [...reviewKeys.nurseLists(), nurseProfileId] as const,
+
+ eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const,
+ myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const,
+};
diff --git a/client/src/services/reviews/types.ts b/client/src/services/reviews/types.ts
new file mode 100644
index 0000000..7b197ec
--- /dev/null
+++ b/client/src/services/reviews/types.ts
@@ -0,0 +1,119 @@
+import type { PageParams, Paginated } from '@/lib/api/types';
+
+/**
+ * Reviews domain — the **moderated trust signal** (b14). A customer leaves **one review per completed
+ * booking**; it is born `pending_moderation` and is **never public / never counted** until an admin (or the
+ * AI pre-screen) publishes it; the public read only ever returns `published` reviews + the recomputed
+ * aggregate. Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` +
+ * `dev/contracts/openapi/swagger.v1.json`), mirroring the wire exactly.
+ *
+ * **What the contract serves (real):** submit a review (`POST bookings/{id}/review`), the public nurse
+ * reviews page (`GET nurses/{id}/reviews` — aggregate + published page), the per-nurse tag rollup, and the
+ * admin moderation transition (admin-only → f15). **What it does NOT serve (client gaps, REQ-026):** a
+ * **review-eligibility** read (whether *this* booking can still be reviewed) and a **my-review-for-booking**
+ * read (the customer's own submitted review + its moderation state, so the CTA can show the persistent
+ * "under review" state). Those two are derived/mocked behind this seam and the domain is **mock-primary**
+ * (see `constants.ts`); when REQ-026 lands only `apis/clientApi.ts`'s two gap methods flip.
+ *
+ * Load-bearing rules (contract + phase §5):
+ * - **Never render `pending_moderation`/`hidden`/`rejected` publicly.** `getNurseReviews` returns published
+ * only (server-filtered); after a submit we show a **local** "under review" state and **never**
+ * optimistically inject the new review into any public list or aggregate.
+ * - **1:1** — one review per booking; a second submit is a `409`.
+ * - Review-eligible = the booking is **completed/closed** AND not already reviewed.
+ * - Tag chip labels are **i18n keys keyed off the code**, never a label off the wire.
+ *
+ * Enums cross the wire as stable string codes — mirrored here as string-literal unions.
+ */
+
+/** `moderationStatus` (contract enum). Born `pending_moderation`; only `published` is ever public/counted. */
+export type ModerationStatus = 'pending_moderation' | 'published' | 'hidden' | 'rejected';
+
+/**
+ * The seeded review-tag vocabulary (contract "Review tag codes"). The chip **label** is the client's i18n
+ * key (`reviews.tag_{code}`), never a label off the wire.
+ */
+export const REVIEW_TAG_CODES = ['punctual', 'professional', 'clean', 'kind', 'communicative'] as const;
+export type ReviewTagCode = (typeof REVIEW_TAG_CODES)[number];
+
+/** A public review row (`ReviewListItemDto`) — **never** carries moderation internals or a customer name. */
+export interface ReviewListItem {
+ id: number;
+ /** 1–5. */
+ rating: number;
+ body: string | null;
+ tagCodes: string[];
+ /** UTC ISO-8601 — Shamsi display is the client's job. */
+ createdAt: string;
+}
+
+/** The nurse rating aggregate (`NurseReviewAggregateDto`) — computed **from published reviews only**, server-side. */
+export interface NurseReviewAggregate {
+ /** 2-dp decimal (e.g. `4.5`). */
+ averageRating: number;
+ publishedCount: number;
+}
+
+/** `NurseReviewsResult` — the public reviews page for a nurse: the aggregate + a page of published reviews. */
+export interface NurseReviews {
+ aggregate: NurseReviewAggregate;
+ reviews: Paginated;
+}
+
+/** The submit body (`SubmitReviewBody`; the booking id comes from the route). */
+export interface CreateReviewRequest {
+ /** 1–5, required. */
+ rating: number;
+ /** ≤ 2000, optional. */
+ body?: string | null;
+ /** Validated against the active vocabulary; optional. */
+ tagCodes?: string[];
+}
+
+/** `SubmitReviewResult`. `moderationStatus` is `pending_moderation` by default (clean text stays pending). */
+export interface SubmitReviewResult {
+ id: number;
+ moderationStatus: ModerationStatus;
+ /** Internal (rating ≤ threshold raised a support alert) — never surfaced to the user; we ignore it in the UI. */
+ lowRatingAlertRaised: boolean;
+}
+
+/** Why a booking cannot be reviewed (client-derived; drives the not-eligible copy). */
+export type ReviewIneligibilityReason = 'not_completed' | 'already_reviewed' | 'not_owner' | 'not_found';
+
+/**
+ * Whether *this* booking can still be reviewed (**REQ-026 — no wire endpoint**). Derived from the booking
+ * status (completed/closed) + the 1:1 rule. Mocked behind the seam; the real client targets a proposed slug.
+ */
+export interface ReviewEligibility {
+ canReview: boolean;
+ reason?: ReviewIneligibilityReason;
+}
+
+/**
+ * The customer's **own** review for a booking + its current moderation state (**REQ-026 — no wire
+ * endpoint**). `status: 'none'` = not yet reviewed. Lets the CTA render the persistent "under review" /
+ * "published" state across sessions without leaking anything public.
+ */
+export interface MyReviewState {
+ status: ModerationStatus | 'none';
+ rating: number | null;
+ body: string | null;
+ tagCodes: string[];
+ createdAt: string | null;
+}
+
+/**
+ * The reviews API seam — the real HTTP client and the in-memory mock both implement this; selection is by
+ * config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks.
+ */
+export interface ReviewsApi {
+ /** Public — published reviews + aggregate for a nurse (server filters to published). */
+ getNurseReviews(nurseProfileId: number, params: PageParams): Promise;
+ /** REQ-026 — can this booking still be reviewed? */
+ getReviewEligibility(bookingId: number): Promise;
+ /** REQ-026 — the customer's own review for this booking + its moderation state. */
+ getMyReviewForBooking(bookingId: number): Promise;
+ /** Submit the one review for a completed booking (`409` if already reviewed). */
+ createReview(bookingId: number, body: CreateReviewRequest): Promise;
+}
diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md
index 0a9d59b..bb9f570 100644
--- a/dev/shared-working-context/frontend/STATUS.md
+++ b/dev/shared-working-context/frontend/STATUS.md
@@ -12,6 +12,32 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
+## frontend-phase-13-b14 — Reviews & patient care records — 2026-07-10
+- **Shipped:** the last feature-domain phase. Two new domains — **`services/reviews`** (`useNurseReviews`
+ infinite published-only aggregate+list / `useReviewEligibility` / `useMyReviewForBooking` / `useCreateReview`
+ — invalidates eligibility+my-review, NEVER the public list) and **`services/patientRecords`**
+ (`usePatientCareRecord` / `useRecordAccess` / `usePatientHistory` / `useUpdateCareRecord` CUSTOMER-only /
+ `useCreateVisitNote` NURSE-only). Screens: `/bookings/[id]/review` (RatingInput + ReviewTagSelector, gated
+ completed+can_review+1:1, under-review state) + `LeaveReviewCta` on the customer booking detail; C3 nurse
+ profile **reviews tab** (published-only aggregate+count+infinite list); E2 **`/patients/[id]/record`** (4 tabs
+ داروها/روتین/سوابق/وظایف + ownership banner + customer edit + non-leaking access-denied); nurse
+ **`NurseVisitNotesPanel`** (append-only task-checklist+note composer BELOW the f8 EVV banner). New shared
+ composites RatingInput/ReviewTagSelector/VisitNoteCard/PatientHeader (each tested; PatientCard now composes
+ PatientHeader + gained `onOpen`); `usePatient(id)`; new icons; `reviews`+`records` i18n namespaces (both
+ locales). Extended the f8 bookings mock: completed booking **5005** + cross-mock read mockGetBookingForReview.
+- **Consumes:** dev/contracts/domains/reviews-records.md + openapi/swagger.v1.json (backend-phase-14). REAL &
+ mapped 1:1: `POST bookings/{id}/review`, `GET nurses/{id}/reviews`, `GET`/`POST patients/{id}/care_records`.
+- **Mocked client-side:** `services/reviews` (`USE_REVIEWS_MOCK`) — eligibility + my-review are REQ-026 gaps,
+ moderation is admin-only/f15 (dev `__mockPublishSubmittedReview`). `services/patientRecords`
+ (`USE_PATIENT_RECORDS_MOCK`) — the visit-note history/append are REAL b14; the family-owned record
+ (meds/routine/tasks) + access check have NO backend (REQ-027). Both default `true`; swap is one flag. See
+ mocks-registry.
+- **Gate:** npm run check green · npm run test:ci green (all suites, +17) · npm run build green with
+ NEXT_PUBLIC_API_URL set. 6-dimension adversarial review with per-finding verification.
+- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-026 review eligibility + my-review + masked
+ author; REQ-027 family-owned care record + access + structured taskResults — flags the E2 record has no
+ data-model entity).
+
## frontend-phase-12-b13 — Nurse earnings & payout history — 2026-07-10
- **Shipped:** the **last money-path frontend phase** — the read-only **nurse earnings** surface. A **new
`services/payouts` domain** (types/keys/constants/apis[client+mock]/4 read-only hooks + hooks-only barrel)
diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md
index 178852d..b7b1e00 100644
--- a/dev/shared-working-context/frontend/requests/for-backend.md
+++ b/dev/shared-working-context/frontend/requests/for-backend.md
@@ -413,3 +413,46 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
`net_amount = gross_earnings − clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
`gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
- **Status:** open
+
+## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10
+- **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve:
+ 1. **Review eligibility** — proposed `GET api/v1/bookings/{bookingId}/review_eligibility` (owner-scoped) →
+ `{ canReview: boolean, reason?: 'not_completed'|'already_reviewed'|'not_owner'|'not_found' }`. Lets the
+ CTA/form gate on "this booking is completed/closed AND not already reviewed" without probing the 1:1 `409`.
+ 2. **My review for a booking** — proposed `GET api/v1/bookings/{bookingId}/my_review` (owner-scoped) →
+ `{ moderationStatus: 'pending_moderation'|'published'|'hidden'|'rejected'|'none', rating?, body?, tagCodes[], createdAt? }`.
+ Lets the CTA show the **persistent "under review" state across sessions** (a returning customer must never
+ see a second form) — today only the just-submitted result is known client-side.
+ 3. **Masked author confirmation.** `ReviewListItemDto` (the public list) carries **no customer name**. The
+ client renders a generic masked author («کاربر بالینیار») on the profile reviews tab. Please confirm this
+ omission is intentional (privacy), or add a **server-masked** display name field if a masked name is wanted.
+- **Why:** b14 serves the submit (`POST bookings/{id}/review`), the public `GET nurses/{id}/reviews`, and the tag
+ rollup — but there is no eligibility read and no my-review read, and moderation (`pending_moderation → published`)
+ is admin-only (f15). So the `services/reviews` domain is **mock-primary** (`USE_REVIEWS_MOCK = true`): the mock
+ reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review
+ state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview`
+ 1:1 and targets the two proposed slugs for the gaps — one config flip when they land.
+- **Status:** open
+
+## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10
+- **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half
+ is **real and consumed**. But the E2 record viewer's **family-owned editable record** and the nurse **task
+ checklist** have **no backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md`
+ models them). Proposed:
+ 1. **Family-owned record** — `GET/PUT api/v1/patients/{patientId}/care_record` (owner-scoped for read;
+ owning-customer for write) → `{ patientId, medications: [{ id, name, dosage?, frequency, timingNote? }],
+ routine: [{ id, label, timeOfDay?, note? }], tasks: [{ id, label, done }] }`. **Patient-scoped, not
+ booking-scoped**; the **customer** owns/edits it; it persists across nurse changes. (New entity/table.)
+ 2. **Record access** — proposed `GET api/v1/patients/{patientId}/record_access` →
+ `{ canView, canEdit, canAppendNote, deniedReason? }` **or** confirm the client should derive access purely
+ from the **`403` on the history read** (the two-stage clinical-disclosure rule). Today the mock returns a
+ `403` for a foreign patient id so the non-leaking access-denied card is demoable.
+ 3. **Structured task results on a visit note** — `WriteCareRecordBody` is only `{ bookingId?, body }`. The
+ nurse's ticked checklist is currently **folded into `body`** as a leading summary line. A structured
+ `taskResults: [{ label, done }]` field would preserve it (and let the history render the checklist as chips).
+- **Why:** E2 (داروها/روتین/سوابق/وظایف) + the nurse E3 task-checklist are the continuity-of-care surface. The
+ visit-note history/append are real (b14) and mapped 1:1 in `patientRecordsClientApi`; the family-record + access
+ methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`)
+ until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether
+ the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking).
+- **Status:** open
diff --git a/dev/shared-working-context/reports/frontend-phase-13-report.md b/dev/shared-working-context/reports/frontend-phase-13-report.md
new file mode 100644
index 0000000..b825932
--- /dev/null
+++ b/dev/shared-working-context/reports/frontend-phase-13-report.md
@@ -0,0 +1,156 @@
+# Frontend Phase 13 (b14) — Reviews & Patient Care Records — report
+
+**Track:** frontend · **Consumes:** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (b14) +
+`openapi/swagger.v1.json` · **Status:** complete, gate green.
+
+The last feature-domain frontend phase: the moderated **review** loop and the **patient care-record** viewer/
+authoring. Two new `services/{domain}` domains, four screens (+ a tab added to C3 + a CTA on the customer
+booking detail), four new shared composites.
+
+---
+
+## 1. What was built
+
+### Services
+- **`services/reviews`** (`types`/`keys`/`constants`/`apis{index,clientApi,mockApi}`/`hooks`/`index`) — the
+ moderated-review domain. Hooks: `useNurseReviews` (infinite, published-only aggregate + list),
+ `useReviewEligibility(bookingId)`, `useMyReviewForBooking(bookingId)`, `useCreateReview` (invalidates
+ eligibility + my-review, **never** the public list/aggregate).
+- **`services/patientRecords`** (same shape) — the continuity-of-care domain, **patient-scoped**. Hooks:
+ `usePatientCareRecord` (family record), `useRecordAccess` (gates before any clinical fetch),
+ `usePatientHistory` (paged visit-note history), `useUpdateCareRecord` (**customer-only** edit → `setQueryData`),
+ `useCreateVisitNote` (**nurse-only** append → invalidates history).
+- **`services/patients`** — added `usePatient(id)` (E2 identity header; the seam already had `get(id)`).
+
+### Screens & flows
+- **Leave-a-review** — `/bookings/[id]/review` (`RatingInput` + body + `ReviewTagSelector`), gated on
+ completed/closed + server `can_review` + 1:1; on submit → persistent **"under review"** state (never public
+ here); already-reviewed shows the review's moderation state, never a second form. Entry: **`LeaveReviewCta`**
+ on the customer booking detail (page-only sibling, reads the cached booking + my-review).
+- **Nurse-profile reviews tab (C3)** — added a «خدمات» / «نظرات» tab strip to the existing profile; the reviews
+ panel renders the **published-only** aggregate (rating + count) + an infinite list. The old single-review
+ snippet is subsumed.
+- **E2 patient record viewer** — `/patients/[id]/record`: reused `PatientHeader` + ownership banner + four tabs
+ (داروها/روتین/سوابق/وظایف). The customer edits medications/routine/tasks; **سوابق** is the read-only nurse
+ visit-note history; a first-class, non-leaking **access-denied** card gates before any clinical fetch. Tapping
+ a `PatientCard` opens it.
+- **Nurse visit-note authoring (E3)** — `NurseVisitNotesPanel` (co-located at `nurse/visits/[id]/`), mounted
+ **below** the f8 EVV banner: today's task checklist + a free-text note composer + the read-only continuity
+ history. **Append-only** — it exposes no record editing and never wires `useUpdateCareRecord`.
+- **Longitudinal history** — patient-scoped, paged (Prev/Next when >1 page), read-only, newest-first, rendered
+ via the shared `VisitNoteCard` in both the E2 سوابق tab and the nurse continuity view; persists across nurse
+ changes (the mock seeds a multi-nurse default).
+
+### Shared composites (each with a co-located `*.test.tsx`)
+- **`RatingInput`** — 1–5 star input/display (custom, on the registered `star` `AppIcon`; filled
+ `var(--bal-warning)` / empty `var(--bal-divider)`; interactive = radiogroup, readOnly = static).
+- **`ReviewTagSelector`** — multi-select review-tag chip group (i18n-free; caller passes `labelFor(code)`).
+- **`VisitNoteCard`** — one read-only visit note (nurse name + Shamsi date + body + done/not-done task chips).
+- **`PatientHeader`** — extracted from `PatientCard` so E1 + E2 share the identity header; `PatientCard` now
+ composes it + gained an optional `onOpen` tap handler.
+- New icons registered: `notes`, `routine`, `tasks`, `history`, `family`.
+- i18n: new top-level `reviews` + `records` namespaces (both locales, in sync) + `search.tab_{services,reviews}`
+ + `patients.open_record`.
+
+---
+
+## 2. What is testable and exactly how (per phase §7)
+
+Run `npm run dev` (with `NEXT_PUBLIC_API_URL` set; the b14 backend reachable or the seam mocks active — both
+default on).
+
+1. **Leave a review on a completed booking → pending → appears after moderation.** As a customer, open the
+ completed **booking 5005** (seeded in the f8 mock) → its detail shows **«ثبت نظر»**. Open it, give 4 stars +
+ body + a tag chip, submit → the screen shows **«در حال بررسی / under review»** and the CTA no longer offers a
+ second review. The review does **not** appear on the nurse's profile. To watch it publish (the f15 admin path
+ is deferred), in the browser console call the reviews mock's dev helper
+ `__mockPublishSubmittedReview(5005)` (exported from `services/reviews/apis/mockApi.ts`) → it appears on the
+ **nurse 1** profile reviews tab and the aggregate updates on the next fetch. A **cancelled** booking (5004)
+ shows no review CTA (not completed).
+2. **View a patient record with tabs + ownership banner.** Patients tab → tap a patient → E2 shows the four
+ tabs, medication cards under داروها, the **«این پرونده متعلق به خانواده است …»** banner, and the customer can
+ edit a medication/routine/task (**ویرایش** → change → **ذخیره**) and see it persist (cache updates via
+ `setQueryData`, no reload). Navigating to **`/patients/8888/record`** (the `MOCK_FOREIGN_PATIENT_ID`) shows
+ the **access-denied** card, not a crash.
+3. **A nurse appends a visit note (cannot edit the record).** As the nurse, open a visit (`/nurse/visits/5005`)
+ → below the EVV banner, tick the task checklist, write a note, **«ثبت یادداشت»** → the note saves and shows in
+ the history. There is **no** medication/routine/task edit control anywhere in the nurse view.
+4. **History persists across nurse changes.** The سوابق tab (customer) and the nurse continuity view show the
+ patient-scoped, newest-first timeline — including seeded notes from a **different** nurse (سارا محمدی) —
+ paginated.
+5. **Gate checks:** `npm run check` green; `npm run test:ci` green (all suites); the reviews tab/list + record
+ edit show query caching + invalidation in React Query Devtools (no needless refetch).
+
+---
+
+## 3. What is mocked client-side + how it swaps
+
+Both domains are **mock-primary** behind their `services/{domain}` seams (`USE_REVIEWS_MOCK`,
+`USE_PATIENT_RECORDS_MOCK`, default `true`). See [`mocks-registry.md`](./mocks-registry.md) for the full rows.
+
+- **Reviews:** `reviewsClientApi.getNurseReviews`/`createReview` map the live b14 routes **1:1**; the mock adds
+ what b14 doesn't serve — review-eligibility + my-review-for-booking (**REQ-026**) — and stands in for the
+ admin-only moderation (f15) via `__mockPublishSubmittedReview`. Swap = flip `USE_REVIEWS_MOCK` once REQ-026 lands.
+- **Patient records:** `patientRecordsClientApi.getPatientHistory`/`createVisitNote` map the **real** b14
+ `care_records` GET/POST 1:1; the mock adds the family-owned record + access check (**REQ-027**, no backend
+ entity exists). Swap = flip `USE_PATIENT_RECORDS_MOCK` once REQ-027 lands (only the family-record/access
+ methods change).
+- **f8 bookings mock (non-seam):** added **booking 5005** (`completed`) so a review is demoable + the cross-mock
+ read helper `mockGetBookingForReview` (one-way edge into bookings, no cycle).
+
+---
+
+## 4. Contracts consumed / requests filed
+
+- **Consumed (real, mapped 1:1):** `POST bookings/{id}/review`, `GET nurses/{id}/reviews`,
+ `GET`/`POST patients/{id}/care_records`.
+- **Filed** to [`for-backend.md`](../frontend/requests/for-backend.md):
+ - **REQ-026** — review-eligibility read + my-review-for-booking read + confirm the masked-author omission on
+ `ReviewListItemDto`.
+ - **REQ-027** — the family-owned `care_record` (medications/routine/tasks) GET/PUT + a `record_access` read
+ (or derive from the 403) + structured `taskResults` on a visit note. Flags that the wireframe's four-tab E2
+ record has **no data-model entity** — needs a product decision.
+
+---
+
+## 5. Follow-ups for later phases
+
+- **⚠ Discovered pre-existing infra defect (repo-wide, NOT in the f13 diff): the ESLint unused-vars gate is a
+ no-op.** `client/eslint.config.mjs` tries to raise `@typescript-eslint/no-unused-vars` to `error` by
+ `.map()`-patching `eslint-config-next`'s default export — but that export never carries the rule (it lives in
+ the `eslint-config-next/typescript` subpath, which the config doesn't spread), so the patch matches nothing
+ and the rule is **never enabled** (verified via `eslint --print-config` — zero active `@typescript-eslint`
+ rules). `tsconfig.json` also lacks `noUnusedLocals`. Net effect: **unused imports/vars pass `npm run check`
+ silently**, contradicting `client/CLAUDE.md` golden rule #11 and the config's own comment. This is why the
+ adversarial review (not the gate) caught three dead imports in this diff — now removed; **the f13 diff is
+ verified 0 unused via `tsc --noEmit --noUnusedLocals --noUnusedParameters`.** The fix (spread
+ `eslint-config-next/typescript`, or re-register the rule in an explicit `**/*.{ts,tsx}` override, + fix the
+ false CLAUDE.md/config comments, + optionally add `noUnusedLocals`) is **deliberately deferred** here: it
+ surfaces ~6 pre-existing violations in earlier-phase files, so it wants a focused repo-wide cleanup + a
+ team decision, not a f13-scoped change. **Recommend a dedicated infra task.**
+- **Review moderation UI** (admin approve/hide/reject queue) is **DEFERRED → f15** (`frontend-phase-15-b15`).
+ The client is publish-only; the dev helper stands in until then.
+- **Tag-aggregation dashboards** ("% punctual") are deferred (the `GET nurses/{id}/review_tags` endpoint exists
+ and could back them). This phase renders per-review tag chips only.
+- The **in-app "raise a concern" flag + emergency banner** → `frontend-phase-14-b15`.
+- Once REQ-026/027 land, flip the two mock flags; no hook/component change.
+
+---
+
+## 6. Gate
+
+`npm run check` green (tsc + eslint). `npm run test:ci` green (257 tests, 58 suites; +17 new across
+`RatingInput`/`ReviewTagSelector`/`VisitNoteCard`/`PatientHeader`/`PatientCard`). `npm run build` green with
+`NEXT_PUBLIC_API_URL` set (all new routes compile; the env-required build failure without it is pre-existing and
+unrelated). The f13 diff is verified **0 unused** via `tsc --noEmit --noUnusedLocals --noUnusedParameters`.
+
+A **6-dimension adversarial review** (contract-fidelity, caching, security/access, i18n/RTL/tokens,
+React-correctness, flow-integrity) ran with per-finding adversarial verification. Outcome:
+- **Confirmed & fixed:** 3 dead imports (`Box` + `RECORD_HISTORY_PAGE_SIZE` in the E2 page,
+ `mockListBookingsForReview` in the reviews mock — the latter's orphaned bookings-mock export also removed);
+ the now-inaccurate history-only invalidation doc comment; the orphaned `search` i18n keys my C3 refactor left;
+ an unreachable task-checklist skeleton branch.
+- **Confirmed but deferred:** the pre-existing ESLint-gate no-op (§5, repo-wide infra).
+- **Verified false-positive (no change):** the `409`-for-not-completed (the contract specifies no status for
+ that case, only for the 1:1 `409`); the security/access dimension raised **nothing** (published-only,
+ append-only, and the non-leaking access gate all hold); the caching dimension raised **nothing**.
diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md
index 5328b5a..57d8321 100644
--- a/dev/shared-working-context/reports/mocks-registry.md
+++ b/dev/shared-working-context/reports/mocks-registry.md
@@ -77,3 +77,6 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1–D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجیپی 3/6/12 · اسنپپی ۴ · اقساط بالینیار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجیپی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false` — `checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائهدهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 5001–5004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross − clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false` — `payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |
+| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false` — `reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟡 |
+| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888` → `canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
+| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper** — `mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟡 |