# Nurse-side workspace — dashboard, profile, service pricing (variant builder), coverage, bank

## Current state

The nurse workspace lives under client/src/app/[locale]/(private-routes)/nurse/, wrapped by RoleGuard + NurseLayout (client/src/layout/NurseLayout.tsx), which renders the starter-derived TopBarAndSideBarLayout: fixed top bar + a 240px persistent desktop sidebar with a flat 10-item nav (dashboard, requests, profile, services, coverage, bank, verification, visits, earnings, support). The nurse home (nurse/page.tsx) is literally PlaceholderScreen — an icon, the nav title, and generic "coming later" copy — so the landing surface of the entire nurse business is empty while every ingredient of a real dashboard already exists as a cached hook elsewhere (useNurseRequestInbox, useNurseEarningsBalance, useVerificationStatus, bank/coverage/variant queries).

The functional pages are competent, narrow form columns (each sets its own maxWidth 560–640 and hugs the start edge of the wide shell). Profile (nurse/profile/page.tsx) edits only avatar + bio + years, shows the TrustBadge and a blocked-until-verified banner, and passes education/specialization fields through untouched. Services (nurse/services/page.tsx) switches in-page between MyServicesList (VariantCard rows with soft deactivate/reactivate, skeletons, a good dashed empty state, and the PublishGate verification banner) and VariantBuilder — a 3-step create stepper (CategoryTile grid → option-group ToggleButtonGroups with required badges → Toman price entry with a live PriceDisplay estimate and auto-generated display name); edit mode locks category/options and edits price only. Coverage (nurse/coverage/page.tsx) renders areas as chips, an add card with a whole-city/districts scope toggle plus CascadingRegionSelect (province→city→district, aggressively cached, loading adornments), inline duplicate blocking mirrored to the server 409, and a confirm dialog on remove. Bank (nurse/bank/page.tsx) renders each account through BankStatusPanel's three semantic states (pending/verified/mismatch) with masked LTR IBAN, a make-primary action, and a re-enter path on mismatch.

Styling is token-disciplined: zero hard-coded hexes in the whole nurse tree (grep-verified), all color through --bal-* semantic tokens, accents via RTL-safe borderInlineStart. But the composition is default-MUI: plain bordered Papers, MUI Stepper header, ToggleButtonGroups, h5+body2 page headers repeated by hand, and the AppButton starter component whose built-in margin every call site cancels with sx={{ m: 0 }}. The systemic behavioral gap is error handling: most queries destructure only { data, isLoading }, so a failed request renders the *empty* state, and several mutations have no onError at all.

## Problems (16)

- **[high]** `client/src/app/[locale]/(private-routes)/nurse/page.tsx` — The nurse dashboard — the landing page of the whole workspace — is a PlaceholderScreen with generic 'placeholder_body' copy. A nurse running their business here gets no today's visits, no pending-request count, no earnings snapshot, no verification/setup status, even though every one of those hooks already exists (useNurseRequestInbox, useNurseEarningsBalance, useVerificationStatus, useMyVariants, useServiceAreas, useNurseBankAccounts).
  - evidence: line 7: `return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;`
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx` — The requests inbox has no error state: it destructures only { data, isLoading }, so a failed useNurseRequestInbox query renders the 'no incoming requests' empty state. A nurse can silently miss paid work while requests are actually pending — with per-request response deadlines ticking. Income-critical false negative.
  - evidence: line 19 `const { data, isLoading } = useNurseRequestInbox();` + lines 33–45: only `isLoading ? skeleton : items.length === 0 ? empty : list`
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx` — Once an account is verified there is no way to add another bank account or change IBAN: the form only appears when accounts.length === 0 or after a mismatch re-enter (setShowForm(true) exists only in the mismatch branch). Yet 'make primary' (line 85) implies multiple accounts are supported. A nurse who switches banks is dead-ended on the money path. Additionally, a failed useNurseBankAccounts query renders the 'no account yet' empty state + open form, inviting a duplicate IBAN submission.
  - evidence: line 31 `const showFormNow = !isLoading && (accounts.length === 0 || showForm);` — showForm set only at line 81 `onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}`
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx` — The 'publish' primary CTA is a no-op that fires a success snackbar — nothing is published, but the UI claims completion. On a trust-first platform a fake success on the go-live action is a product-integrity bug, and the panel occupies prime space on every visit to the services list even when approved.
  - evidence: line 64: `onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}`
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx` — Profile save and avatar upload fail silently: both mutations pass only onSuccess (lines 44, 63), and the hooks (services/profiles/hooks/useUpsertNurseProfile.ts, useUploadAvatar.ts) define no onError — a failed save shows nothing, and a nurse may leave believing their trust-critical profile is saved. Also the uploaded avatar is only staged in local state; leaving without pressing 'save' discards it with no warning.
  - evidence: line 44 `uploadAvatar.mutate(file, { onSuccess: ... })` and lines 54–64 `upsert.mutate(..., { onSuccess: () => enqueueSnackbar(...) })` — no onError anywhere
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx` — Same error→false-empty pattern: a failed useMyVariants query renders the 'create your first service' empty state (isEmpty = !isLoading && variants.length === 0), telling an established nurse their offerings are gone / never existed.
  - evidence: lines 37, 41–42: `const { data, isLoading } = useMyVariants(); ... const isEmpty = !isLoading && variants.length === 0;`
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — Step 2 treats a failed option-groups query as 'this category needs no options' (only isLoading and groups.length === 0 are handled), letting the nurse advance and submit a variant missing required option groups, which then dies with a generic create_error toast. The categories step handles isError with retry (lines 348–356) but the options step does not.
  - evidence: lines 384–389: `optionGroupsQuery.isLoading ? <AppLoading /> : groups.length === 0 ? t('options_none') : ...` — no isError branch
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx` — The whole-city choice is encoded twice and contradicts itself: the scope ToggleButtonGroup (lines 198–210) selects whole-city vs districts, but when 'specific districts' is chosen, CascadingRegionSelect's district dropdown still offers its own 'whole city' empty MenuItem (CascadingRegionSelect.tsx line 152) — picking it then trips the 'district required' error on add. Also removeArea.mutate has no onError (lines 124–126): a failed removal leaves the chip with no feedback.
  - evidence: coverage handleAdd line 89: `const districtInvalid = effectiveScope === 'districts' && region.districtId == null;` vs CascadingRegionSelect.tsx line 152: `<MenuItem value="">{t('whole_city')}</MenuItem>`
- **[medium]** `client/src/layout/config.ts` — Sidebar anchoring is physical, not logical, and inconsistent per breakpoint: desktop drawer anchors 'left' while mobile anchors 'right', regardless of locale — in the default fa/RTL app the desktop nav sits on the trailing edge (unconventional for RTL) and switches sides between mobile and desktop. TopBarAndSideBarLayout offsets content with physical paddingLeft/paddingRight keyed to the anchor string (lines 53–60). Leftover starter comments ('// 'right';') confirm this was never decided for RTL.
  - evidence: lines 8–9: `export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';` / `export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';`
- **[medium]** `client/src/components/common/AppButton/AppButton.tsx` — Starter-grade AppButton ships a default 8px margin on all sides (DEFAULT_SX_VALUES = { margin: 1 }), which every nurse-workspace call site individually fights with sx={{ m: 0 }} (profile, coverage, bank, services, builder — ~15 occurrences). Any forgotten override yields off-grid spacing; spacing should come from layout gaps, not the button.
  - evidence: lines 9–11: `const DEFAULT_SX_VALUES = { margin: 1, ... }`
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx` — Education level/field and specializations — trust-relevant credentials in a healthcare marketplace — exist in the data model but are not editable anywhere: the form silently round-trips initial values, so nurses can never present their qualifications. The page even shows a 'deferred_services' caption admitting the gap.
  - evidence: lines 58–60: `educationLevel: initial?.educationLevel ?? '', educationField: initial?.educationField ?? '', specializationsJson: initial?.specializationsJson ?? '[]'`
- **[medium]** `client/src/layout/TopBarAndSideBarLayout.tsx` — The content area gets a flat 8px gutter (paddingLeft/Right/Top: 1) and no container system, while every nurse page sets its own maxWidth (560 on profile/coverage/bank/builder, 640 on services list, none on earnings) and start-aligns — so on desktop the workspace reads as a narrow form column stuck in the corner of a mostly-empty page. Classic starter-dashboard composition, not a designed workspace.
  - evidence: line 102: `sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}` vs MyServicesList.tsx line 73 `maxWidth: 640` and coverage/page.tsx line 130 `maxWidth: 560`
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — Option values render in a ToggleButtonGroup forced to wrap (sx={{ flexWrap: 'wrap' }}); MUI's grouped-button styling (collapsed borders/negative margins, first/last corner rounding) is designed for a single row, so wrapped rows show missing side borders and squared corners on mid-row buttons once a group has many values.
  - evidence: line 424: `sx={{ flexWrap: 'wrap' }}` on ToggleButtonGroup
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — The duplicate-listing warning sets body text color to var(--bal-warning) (amber) on the paper background — likely failing WCAG contrast in light mode; warning tokens elsewhere are used as chip backgrounds with a dedicated -contrast foreground. The price field also shows raw ungrouped digits (up to 12) while typing — a mis-typed extra zero is a 10× price error; grouping only appears in the estimate panel below.
  - evidence: line 263: `<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 600 }}>`
- **[low]** `client/src/components/PlaceholderScreen/PlaceholderScreen.tsx` — The single sparing terracotta accent (--bal-secondary) is spent on placeholder icons for unfinished screens — the brand accent's most prominent appearance in the nurse workspace is on an empty page, inverting its purpose.
  - evidence: line 24: `<AppIcon icon={icon} size={48} color="var(--bal-secondary)" />`
- **[low]** `client/src/components/StepperHeader/StepperHeader.tsx` — The builder's progress header is a bare default MUI Stepper (default connector, default dot/check icons) — the most default-MUI element in the nurse flow, on the screen a nurse uses to define their core business offering. Page headers likewise are hand-repeated h5+body2 blocks on all five pages with no shared PageHeader component.
  - evidence: lines 21–27: unstyled `<Stepper activeStep={...} alternativeLabel>`

## Opportunities (8)

- **Build the real nurse dashboard ('Today') — pure assembly, all data hooks exist** (impact: high, effort: medium) — Replace the placeholder with a working day-runner: (1) greeting header with name + TrustBadge; (2) 'needs your response' strip — pending requests from useNurseRequestInbox with per-card CountdownTimer, the single most time-critical thing a nurse can miss; (3) today's visits from the bookings/sessions queries with a check-in CTA (EVV lives at /nurse/visits already); (4) an earnings snapshot card from useNurseEarningsBalance (net payable + next weekly payout day) deep-linking to /nurse/earnings; (5) a verification/publish status card when not yet approved. Every widget is a read of an already-cached query — no new services needed.
- **'Go live' setup checklist replacing the scattered warnings** (impact: high, effort: medium) — Verification banner (profile), PublishGate (services), empty-coverage warning (coverage), and bank empty state are four disconnected nags for one journey. Build one activation tracker — verified ✓, profile complete ✓, ≥1 active service ✓, ≥1 coverage area ✓, verified primary IBAN ✓ — with a progress meter, shown on the dashboard until complete and linked from each page's banner. This converts anxiety ('why am I not bookable?') into a guided funnel and directly drives supply-side activation.
- **Public-profile preview: 'how families see you'** (impact: high, effort: small) — Nurses can't see their own listing as customers do. Add a preview mode composing the existing C3 public-profile pieces (avatar, TrustBadge, bio, ServicePriceRow list, coverage chips) from the nurse's own data, reachable from profile and services pages. On a trust-first marketplace this is both a confidence tool and the strongest motivator to complete bio/photo/credentials.
- **Shared query error/empty boundary to kill the error→false-empty pattern** (impact: high, effort: small) — Earnings already has local ErrorPanel/EmptyPanel/retry (nurse/earnings/page.tsx lines 153–181). Extract them into shared components (or a small QueryStateGate wrapper) and apply across requests inbox, services list, bank, coverage, and the builder's options step. One small primitive fixes five misleading states at once and standardizes the retry affordance.
- **Variant builder: live listing preview + smarter duplicate handling** (impact: medium, effort: small) — In step 3, render the actual VariantCard as a live 'this is what appears in search' preview (name, category, PriceDisplay) instead of only the price paper — the nurse is composing a listing, show the listing. On a 409 duplicate, offer 'edit the existing listing' (the list already knows it) rather than a dead-end warning. Consider chips instead of wrapped ToggleButtonGroups for option values, which also fixes the grouped-border artifact.
- **Coverage map visualization** (impact: medium, effort: medium) — Coverage is text chips only, yet AddressMapPicker/Neshan tiles already exist in components/geography. Rendering covered city/district shapes (or even pins) on a small map makes 'where will I appear in search' tangible and catches mistakes (wrong city, forgotten district) instantly. Also collapse the double whole-city affordance: let the district dropdown's 'whole city' option BE the choice and drop the separate scope toggle.
- **Mobile bottom navigation for the daily loop** (impact: medium, effort: medium) — Nurses on shift are on phones; today the 10-item nav hides behind a hamburger in a drawer that opens from the opposite side than on desktop. Give the nurse app a 4–5 tab bottom bar (Today, Requests, Visits, Earnings, More) and demote setup pages (profile/services/coverage/bank/verification) to the 'More' sheet — daily ops one thumb-tap away.
- **Bank: add-account and change-IBAN flow** (impact: medium, effort: small) — Beyond the missing 'add another account' button, design the state properly: an 'accounts' section with a persistent add CTA, the pending poll surfaced as an explicit 'we are checking ownership, usually takes X' timeline, and a guarded flow for replacing the primary IBAN (new account → verify → make primary → optionally remove old). This is the nurse's paycheck; it should feel like a bank settings page, not a one-shot form.

## Keep (do not regress)

- Token discipline is genuinely excellent: zero hard-coded hexes anywhere in the nurse tree (grep-verified); all color flows through --bal-* semantic tokens with -contrast pairs (StatusChip, TrustBadge, BankStatusPanel, PublishGate), so dark mode switches for free.
- RTL-safe logical properties for status accents — borderInlineStartWidth/borderInlineStartColor on every banner/panel (profile banner, coverage warning, BankStatusPanel, PublishGate, duplicate warning) — and LTR-pinned numeric inputs (IBAN, years, price) with textAlign:'start'.
- Money correctness in the UI: PriceDisplay computes totals integer-safe via BigInt, never shows a total from rate alone, Toman entry converts to IRR at the field boundary, and the builder shows a live grouped-Toman estimate panel.
- BankStatusPanel's three-state design (pending/verified/mismatch) with semantic accent edge, masked dir="ltr" IBAN, non-accusatory mismatch copy, and re-enter as the only action — exactly right for a money-trust surface.
- TrustBadge honesty-by-construction (verified only when the aggregate is approved; expired visually distinct from never-verified) and its reuse from own-profile to search results.
- Two-stage disclosure honored in the requests inbox (notes preview only, never address/clinical data) plus server-frozen CountdownTimer — the privacy model is visible in the UI code.
- Soft deactivate semantics on VariantCard: no delete affordance at all, confirm dialog only for the destructive direction, instant reactivate, dimmed + neutral chip + 'can't be booked' hint on inactive rows.
- Edit mode of the variant builder correctly locks identity fields (category + option set) with an explanatory caption — EAV identity semantics surfaced honestly instead of letting an edit silently create a different listing.
- CascadingRegionSelect is production-grade: cached geo queries, out-of-range prefill guard against MUI Select warnings, per-level loading adornments, whole-city-only cities force the right affordance instead of dead-ending.
- Duplicate coverage handling both belt (client-side areaExists pre-check) and braces (server 409 mapped to the same inline message).
- Empty states with dashed border + icon + CTA (services list, bank, requests) and rounded skeletons on most lists — the vocabulary exists; it just needs error-state siblings.
- Locale-aware digits everywhere (Intl fa-IR for counts/pagers) and Shamsi dates in the inbox.
