diff --git a/client/CLAUDE.md b/client/CLAUDE.md index e3276d4..421b984 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -122,12 +122,14 @@ client/ │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── bookings/page.tsx # /bookings │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) + │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── wallet/page.tsx # /wallet │ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) + │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked) │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch) │ │ │ ├── verification/page.tsx # /nurse/verification │ │ │ └── visits/page.tsx # /nurse/visits (EVV) @@ -151,6 +153,7 @@ client/ │ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit │ ├── PatientCard/ # E1 patient summary card + edit/archive actions │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN + │ ├── 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 ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' @@ -195,6 +198,9 @@ client/ │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states + │ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search) + │ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) + │ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city) │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -282,6 +288,9 @@ async function MyServerComponent() { - `'profile'` — the customer profile + emergency contact - `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder) - `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states) +- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints) +- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link +- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings) - `'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 @@ -518,6 +527,12 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` - **Caching is deliberate:** set a `staleTime` on reads so revisiting a screen doesn't refetch; mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the cache stale. See `services/patients/hooks/*`. +- **Reference data is cached for the whole session:** rarely-changing lookups (the geo province→city→district + hierarchy) use an **Infinite `staleTime`** + a shared, hierarchical key factory (`geographyKeys`) so each + level is fetched **once** and served from cache across every consumer (the address form, the coverage editor, + and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage + areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future + reference data; do not reinvent per-consumer fetching. - **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in diff --git a/client/messages/en.json b/client/messages/en.json index 4d5b6b2..cb62e4a 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -6,6 +6,7 @@ "wallet": "Wallet", "profile": "Profile", "bank": "Bank account", + "coverage": "Coverage", "dashboard": "Dashboard", "verification": "Verification", "visits": "Visits", @@ -165,6 +166,75 @@ "empty_title": "No bank account yet", "empty_body": "Add a bank account in your own name to receive payouts." }, + "geo": { + "province": "Province", + "city": "City", + "district": "District", + "whole_city": "Whole city", + "city_needs_province": "Choose a province first", + "district_needs_city": "Choose a city first", + "district_optional": "Optional", + "no_districts": "This city has no districts — whole city" + }, + "address": { + "title": "Addresses", + "subtitle": "Where nurses will visit.", + "add": "Add address", + "add_title": "Add address", + "edit_title": "Edit address", + "edit": "Edit", + "delete": "Delete", + "empty_title": "No addresses yet", + "empty_body": "Add your first address so nurses know where to go.", + "primary": "Primary", + "set_primary": "Set as primary", + "primary_set": "Primary address updated", + "delete_title": "Delete address?", + "delete_body": "This address is removed from your list.", + "delete_confirm": "Delete", + "deleted": "Address deleted", + "saved": "Address saved", + "save_error": "This address couldn't be saved. Please try again.", + "unavailable": "This address isn't available to you.", + "title_label": "Address label", + "title_placeholder": "e.g. Home, Work", + "title_required": "Enter a label for this address", + "line_label": "Street address", + "line_hint": "Building, street, unit — the detail a nurse needs to find the door.", + "line_required": "Enter the street address", + "city_required": "Select a city", + "set_primary_toggle": "Set as primary address", + "map_hint": "Tap or drag the marker to the patient's exact location.", + "map_required": "Drop a pin on the map", + "map_lat": "Lat", + "map_lng": "Lng", + "manage_title": "Your addresses", + "manage_body": "Manage the places nurses visit.", + "manage_cta": "Manage addresses" + }, + "coverage": { + "title": "Coverage areas", + "subtitle": "The cities and districts you'll travel to.", + "areas_heading": "Your areas", + "add_title": "Add a coverage area", + "scope_label": "Coverage", + "scope_whole_city": "Whole city", + "scope_districts": "Specific districts", + "add": "Add area", + "adding": "Adding…", + "whole_city_chip": "Whole city", + "empty_title": "No coverage areas yet", + "empty_warning": "You won't appear in search until you add at least one coverage area.", + "duplicate": "You already cover this area.", + "city_required": "Select a city", + "district_required": "Select a district, or switch to whole city.", + "added": "Coverage area added", + "add_error": "This area couldn't be added. Please try again.", + "remove_title": "Remove coverage area?", + "remove_body": "You'll no longer be matched for visits in this area.", + "remove_confirm": "Remove", + "removed": "Coverage area removed" + }, "auth": { "customer_title": "Sign in to Balinyaar", "customer_subtitle": "Sign in with your mobile number", diff --git a/client/messages/fa.json b/client/messages/fa.json index 6af55fa..483d6fd 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -6,6 +6,7 @@ "wallet": "کیف‌پول", "profile": "پروفایل", "bank": "حساب بانکی", + "coverage": "پوشش", "dashboard": "داشبورد", "verification": "احراز هویت", "visits": "ویزیت‌ها", @@ -165,6 +166,75 @@ "empty_title": "هنوز حسابی ثبت نشده", "empty_body": "برای دریافت درآمد، یک حساب بانکی به نام خودتان اضافه کنید." }, + "geo": { + "province": "استان", + "city": "شهر", + "district": "منطقه", + "whole_city": "کل شهر", + "city_needs_province": "ابتدا استان را انتخاب کنید", + "district_needs_city": "ابتدا شهر را انتخاب کنید", + "district_optional": "اختیاری", + "no_districts": "این شهر منطقه‌بندی ندارد — کل شهر" + }, + "address": { + "title": "آدرس‌ها", + "subtitle": "جایی که پرستار به آن مراجعه می‌کند.", + "add": "افزودن آدرس", + "add_title": "افزودن آدرس", + "edit_title": "ویرایش آدرس", + "edit": "ویرایش", + "delete": "حذف", + "empty_title": "هنوز آدرسی ثبت نشده", + "empty_body": "اولین آدرس را اضافه کنید تا پرستار بداند کجا بیاید.", + "primary": "آدرس اصلی", + "set_primary": "انتخاب به‌عنوان آدرس اصلی", + "primary_set": "آدرس اصلی به‌روزرسانی شد", + "delete_title": "حذف آدرس؟", + "delete_body": "این آدرس از فهرست شما حذف می‌شود.", + "delete_confirm": "حذف", + "deleted": "آدرس حذف شد", + "saved": "آدرس ذخیره شد", + "save_error": "ذخیرهٔ این آدرس ممکن نشد. دوباره تلاش کنید.", + "unavailable": "این آدرس در دسترس شما نیست.", + "title_label": "عنوان آدرس", + "title_placeholder": "مثلاً خانه، محل کار", + "title_required": "برای این آدرس یک عنوان وارد کنید", + "line_label": "نشانی کامل", + "line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن در نیاز دارد.", + "line_required": "نشانی کامل را وارد کنید", + "city_required": "شهر را انتخاب کنید", + "set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود", + "map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.", + "map_required": "روی نقشه یک پین بگذارید", + "map_lat": "عرض", + "map_lng": "طول", + "manage_title": "آدرس‌های شما", + "manage_body": "مکان‌هایی که پرستار به آن‌ها می‌رود را مدیریت کنید.", + "manage_cta": "مدیریت آدرس‌ها" + }, + "coverage": { + "title": "مناطق تحت پوشش", + "subtitle": "شهرها و مناطقی که به آن‌ها می‌روید.", + "areas_heading": "مناطق شما", + "add_title": "افزودن منطقهٔ تحت پوشش", + "scope_label": "گستره", + "scope_whole_city": "کل شهر", + "scope_districts": "مناطق خاص", + "add": "افزودن منطقه", + "adding": "در حال افزودن…", + "whole_city_chip": "کل شهر", + "empty_title": "هنوز منطقه‌ای ثبت نشده", + "empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جست‌وجو نمایش داده نمی‌شوید.", + "duplicate": "این منطقه از قبل تحت پوشش شماست.", + "city_required": "شهر را انتخاب کنید", + "district_required": "یک منطقه انتخاب کنید یا به کل شهر تغییر دهید.", + "added": "منطقهٔ تحت پوشش اضافه شد", + "add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.", + "remove_title": "حذف منطقهٔ تحت پوشش؟", + "remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید.", + "remove_confirm": "حذف", + "removed": "منطقهٔ تحت پوشش حذف شد" + }, "auth": { "customer_title": "ورود به بلینیار", "customer_subtitle": "با شماره موبایل خود وارد شوید", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx new file mode 100644 index 0000000..a407244 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx @@ -0,0 +1,216 @@ +'use client'; +import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Skeleton, + Stack, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon } from '@/components'; +import { AddressCard, AddressForm } from '@/components/geography'; +import { + useAddresses, + useCreateAddress, + useUpdateAddress, + useDeleteAddress, + useSetPrimaryAddress, +} from '@/services/addresses'; +import type { CreateAddressInput, CustomerAddress } from '@/services/addresses/types'; + +/** + * The customer address book — a cached, invalidate-on-mutation list of the customer's saved + * addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm), + * and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen + * address later feeds the f7 booking request. + */ +export default function AddressesPage() { + const t = useTranslations('address'); + const tc = useTranslations('common'); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + + const { data, isLoading } = useAddresses(); + const createAddress = useCreateAddress(); + const updateAddress = useUpdateAddress(); + const deleteAddress = useDeleteAddress(); + const setPrimary = useSetPrimaryAddress(); + + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const openAdd = () => { + setEditing(null); + setFormOpen(true); + }; + const openEdit = (address: CustomerAddress) => { + setEditing(address); + setFormOpen(true); + }; + const closeForm = () => setFormOpen(false); + + // Address district is optional granularity: show "city · district" when set, else just the city. + const regionLabel = (address: CustomerAddress) => { + const city = locale === 'en' ? address.cityNameEn : address.cityNameFa; + if (address.districtId == null) return city; + const district = locale === 'en' ? address.districtNameEn : address.districtNameFa; + return `${city} · ${district}`; + }; + + const handleSubmit = (input: CreateAddressInput) => { + const onSuccess = () => { + closeForm(); + enqueueSnackbar(t('saved'), { variant: 'success' }); + }; + const onError = () => enqueueSnackbar(t('save_error'), { variant: 'error' }); + if (editing) { + updateAddress.mutate({ id: editing.id, input }, { onSuccess, onError }); + } else { + createAddress.mutate(input, { onSuccess, onError }); + } + }; + + const confirmDelete = () => { + if (!deleteTarget) return; + const id = deleteTarget.id; + setDeleteTarget(null); + deleteAddress.mutate(id, { + onSuccess: () => enqueueSnackbar(t('deleted'), { variant: 'success' }), + onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }), + }); + }; + + const addresses = data?.items ?? []; + const isEmpty = !isLoading && addresses.length === 0; + + return ( + + + + + {t('title')} + + + {t('subtitle')} + + + {!isEmpty ? ( + + {t('add')} + + ) : null} + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : isEmpty ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + {t('add')} + + + ) : ( + + {addresses.map((address) => ( + openEdit(address)} + onDelete={() => setDeleteTarget(address)} + onSetPrimary={() => + setPrimary.mutate(address.id, { + onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }), + onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }), + }) + } + settingPrimary={setPrimary.isPending} + editLabel={t('edit')} + deleteLabel={t('delete')} + setPrimaryLabel={t('set_primary')} + /> + ))} + + )} + + + {editing ? t('edit_title') : t('add_title')} + + + + + + + + setDeleteTarget(null)}> + {t('delete_title')} + + + {t('delete_body')} + + + + setDeleteTarget(null)}> + {tc('cancel')} + + + {t('delete_confirm')} + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 21cc32f..333263c 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -1,10 +1,11 @@ 'use client'; import { FunctionComponent, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppLoading, PhoneNumberField } from '@/components'; +import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components'; import { isIranianMobile } from '@/components/PhoneNumberField'; +import { ROUTES } from '@/constants'; import { digitsOnly } from '@/utils'; import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; import type { CustomerProfile } from '@/services/profiles/types'; @@ -18,7 +19,9 @@ export default function CustomerProfilePage() { const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => { const t = useTranslations('profile'); + const ta = useTranslations('address'); const tc = useTranslations('common'); + const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const upsert = useUpsertCustomerProfile(); @@ -123,6 +126,33 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null } > {upsert.isPending ? tc('saving') : t('save')} + + + + {/* Address book lives alongside the profile in the customer area; a booking (f7) needs a + chosen address, so the entry point is surfaced here on the settings hub. */} + + + + + + {ta('manage_title')} + + + {ta('manage_body')} + + + + {ta('manage_cta')} + + + ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx new file mode 100644 index 0000000..7cca6c2 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx @@ -0,0 +1,265 @@ +'use client'; +import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { + Box, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Skeleton, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon } from '@/components'; +import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography'; +import { ApiError } from '@/lib/api/errors'; +import { useDistricts } from '@/services/geography'; +import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas'; +import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types'; + +type Scope = 'whole_city' | 'districts'; +const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; + +/** + * The nurse coverage-area editor — the cities/districts a nurse will travel to, so search (f6) + * can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add + * control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A + * duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to + * the same message). Empty → a warning that the nurse won't appear in search. + */ +export default function NurseCoveragePage() { + const t = useTranslations('coverage'); + const tc = useTranslations('common'); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + + const { data, isLoading } = useServiceAreas(); + const addArea = useAddServiceArea(); + const removeArea = useRemoveServiceArea(); + + const [region, setRegion] = useState(EMPTY_REGION); + const [scope, setScope] = useState('whole_city'); + const [cityError, setCityError] = useState(false); + const [districtError, setDistrictError] = useState(false); + const [duplicate, setDuplicate] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + + const areas = data?.items ?? []; + + // A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the + // same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a + // district that cannot exist. + const districtsQuery = useDistricts(region.cityId); + const cityHasNoDistricts = + region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0; + const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope; + + const chipLabel = (area: NurseServiceArea) => { + const city = locale === 'en' ? area.cityNameEn : area.cityNameFa; + if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`; + const district = locale === 'en' ? area.districtNameEn : area.districtNameFa; + return `${city} · ${district}`; + }; + + const changeScope = (next: Scope | null) => { + if (!next) return; + setScope(next); + setDistrictError(false); + setDuplicate(false); + // Whole-city ignores any picked district — clear it so the submitted pair is unambiguous. + if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null })); + }; + + const resetForm = () => { + setRegion(EMPTY_REGION); + setScope('whole_city'); + setCityError(false); + setDistrictError(false); + setDuplicate(false); + }; + + const handleAdd = () => { + const cityInvalid = region.cityId == null; + const districtInvalid = effectiveScope === 'districts' && region.districtId == null; + setCityError(cityInvalid); + setDistrictError(districtInvalid); + setDuplicate(false); + if (cityInvalid || districtInvalid) return; + + const cityId = region.cityId as number; + const districtId = effectiveScope === 'whole_city' ? null : region.districtId; + + // Fast path: block a duplicate before firing (null district treated as a real value). + if (areaExists(areas, cityId, districtId)) { + setDuplicate(true); + return; + } + + addArea.mutate( + { cityId, districtId }, + { + onSuccess: () => { + resetForm(); + enqueueSnackbar(t('added'), { variant: 'success' }); + }, + onError: (error) => { + // Belt-and-braces: the server's UNIQUE 409 maps to the same inline duplicate message. + if (error instanceof ApiError && error.status === 409) setDuplicate(true); + else enqueueSnackbar(t('add_error'), { variant: 'error' }); + }, + }, + ); + }; + + const confirmRemove = () => { + if (!removeTarget) return; + const id = removeTarget.id; + setRemoveTarget(null); + removeArea.mutate(id, { + onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }), + }); + }; + + return ( + + + + {t('title')} + + + {t('subtitle')} + + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : areas.length > 0 ? ( + + + {t('areas_heading')} + + + {areas.map((area) => ( + setRemoveTarget(area)} + sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }} + /> + ))} + + + ) : ( + + + + + + {t('empty_title')} + + + {t('empty_warning')} + + + + + )} + + + + + {t('add_title')} + + + + + {t('scope_label')} + + changeScope(next)} + > + {t('scope_whole_city')} + {/* A district-less city forces whole-city — disable the option rather than dead-end on it. */} + + {t('scope_districts')} + + + + + { + setRegion(next); + if (cityError && next.cityId != null) setCityError(false); + if (districtError && next.districtId != null) setDistrictError(false); + setDuplicate(false); + }} + includeDistrict={effectiveScope === 'districts'} + cityError={cityError} + cityErrorText={t('city_required')} + districtError={districtError} + districtErrorText={t('district_required')} + /> + + {duplicate ? ( + + {t('duplicate')} + + ) : null} + + + {addArea.isPending ? t('adding') : t('add')} + + + + + setRemoveTarget(null)}> + {t('remove_title')} + + + {t('remove_body')} + + + + setRemoveTarget(null)}> + {tc('cancel')} + + + {t('remove_confirm')} + + + + + ); +} diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index d756f29..431d260 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -37,6 +37,9 @@ import ArchiveIcon from '@mui/icons-material/Inventory2Outlined'; import BankIcon from '@mui/icons-material/AccountBalanceOutlined'; import CameraIcon from '@mui/icons-material/PhotoCameraOutlined'; import WarningIcon from '@mui/icons-material/WarningAmberOutlined'; +import LocationIcon from '@mui/icons-material/LocationOnOutlined'; +import DeleteIcon from '@mui/icons-material/DeleteOutlined'; +import CoverageIcon from '@mui/icons-material/MapOutlined'; /** * List of all available Icon names @@ -89,4 +92,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - bank: BankIcon, camera: CameraIcon, warning: WarningIcon, + location: LocationIcon, + delete: DeleteIcon, + coverage: CoverageIcon, }; diff --git a/client/src/components/geography/AddressCard.test.tsx b/client/src/components/geography/AddressCard.test.tsx new file mode 100644 index 0000000..e9e7854 --- /dev/null +++ b/client/src/components/geography/AddressCard.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import AddressCard, { type AddressCardProps } from './AddressCard'; + +const BASE: AddressCardProps = { + title: 'Home', + regionLabel: 'Tehran · District 1', + addressLine: 'No. 5, Vali St', + isPrimary: false, + primaryLabel: 'Primary', + onEdit: jest.fn(), + onDelete: jest.fn(), + onSetPrimary: jest.fn(), + editLabel: 'Edit', + deleteLabel: 'Delete', + setPrimaryLabel: 'Set as primary', +}; + +function renderCard(overrides: Partial = {}) { + const props = { ...BASE, ...overrides }; + render( + + + , + ); + return props; +} + +describe(' component', () => { + it('renders the title, region label and street line', () => { + renderCard(); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Tehran · District 1')).toBeInTheDocument(); + expect(screen.getByText('No. 5, Vali St')).toBeInTheDocument(); + }); + + it('shows the primary badge and hides set-primary on the primary card', () => { + renderCard({ isPrimary: true }); + expect(screen.getByText('Primary')).toBeInTheDocument(); + expect(screen.queryByText('Set as primary')).not.toBeInTheDocument(); + }); + + it('offers set-primary only on a non-primary card and fires it', async () => { + const user = userEvent.setup(); + const props = renderCard({ isPrimary: false }); + await user.click(screen.getByText('Set as primary')); + expect(props.onSetPrimary).toHaveBeenCalledTimes(1); + }); + + it('calls onEdit and onDelete from the action buttons', async () => { + const user = userEvent.setup(); + const props = renderCard(); + await user.click(screen.getByLabelText('Edit')); + await user.click(screen.getByLabelText('Delete')); + expect(props.onEdit).toHaveBeenCalledTimes(1); + expect(props.onDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/geography/AddressCard.tsx b/client/src/components/geography/AddressCard.tsx new file mode 100644 index 0000000..957a9d1 --- /dev/null +++ b/client/src/components/geography/AddressCard.tsx @@ -0,0 +1,88 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AppButton, AppIconButton } from '@/components/common'; +import StatusChip from '@/components/StatusChip'; + +export interface AddressCardProps { + title: string; + /** Localised "city · district" (or "city · whole city") label, computed by the caller. */ + regionLabel: string; + addressLine?: string | null; + isPrimary: boolean; + primaryLabel: string; + onEdit: () => void; + onDelete: () => void; + onSetPrimary: () => void; + editLabel: string; + deleteLabel: string; + setPrimaryLabel: string; + /** Disables the set-primary action while its mutation is in flight. */ + settingPrimary?: boolean; +} + +/** + * Address summary card for the address book — title + a primary badge (the f0 `StatusChip`), + * the region label and street line, with edit / delete / set-as-primary actions. Presentational: + * all display text is translated by the caller. The set-primary action shows only on non-primary + * cards so the UI never presents two primaries. + * @component AddressCard + */ +const AddressCard: FunctionComponent = ({ + title, + regionLabel, + addressLine, + isPrimary, + primaryLabel, + onEdit, + onDelete, + onSetPrimary, + editLabel, + deleteLabel, + setPrimaryLabel, + settingPrimary = false, +}) => ( + + + + + + {title} + + {isPrimary ? : null} + + + + {regionLabel} + + + {addressLine ? ( + + {addressLine} + + ) : null} + + {!isPrimary ? ( + + {setPrimaryLabel} + + ) : null} + + + + + + + + +); + +export default AddressCard; diff --git a/client/src/components/geography/AddressForm.test.tsx b/client/src/components/geography/AddressForm.test.tsx new file mode 100644 index 0000000..d753638 --- /dev/null +++ b/client/src/components/geography/AddressForm.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; + +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +const PROVINCES = [{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }]; +const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }]; +const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }]; + +jest.mock('@/services/geography', () => ({ + useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }), + useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }), + useDistricts: (cityId: number | null) => ({ data: cityId ? DISTRICTS : [], isLoading: false, isSuccess: cityId != null }), +})); + +import AddressForm from './AddressForm'; + +describe(' component', () => { + it('blocks submit and flags city + pin + required text when empty', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'save' })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText('title_required')).toBeInTheDocument(); + expect(screen.getByText('city_required')).toBeInTheDocument(); + expect(screen.getByText('line_required')).toBeInTheDocument(); + expect(screen.getByText('map_required')).toBeInTheDocument(); + }); + + it('submits the mapped address input from an edit prefill', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'save' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + title: 'Home', + provinceId: 1, + cityId: 101, + districtId: 1001, + addressLine: 'No. 5, Vali St', + latitude: 35.7, + longitude: 51.4, + isPrimary: false, + }); + }); +}); diff --git a/client/src/components/geography/AddressForm.tsx b/client/src/components/geography/AddressForm.tsx new file mode 100644 index 0000000..f846c7c --- /dev/null +++ b/client/src/components/geography/AddressForm.tsx @@ -0,0 +1,164 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import { AppButton } from '@/components/common'; +import { cityCentroid } from '@/services/geography/constants'; +import type { CreateAddressInput, LatLng } from '@/services/addresses/types'; +import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect'; +import AddressMapPicker from './AddressMapPicker'; + +/** Prefill for edit (or empty for add). `provinceId` is needed to prefill the cascade. */ +export interface AddressFormInitial { + title?: string; + provinceId?: number | null; + cityId?: number | null; + districtId?: number | null; + addressLine?: string | null; + latitude?: number | null; + longitude?: number | null; + isPrimary?: boolean; +} + +export interface AddressFormProps { + initial?: AddressFormInitial; + submitting?: boolean; + onSubmit: (input: CreateAddressInput) => void; + onCancel?: () => void; +} + +// Only prefill the region when we have the province — a city id without its province can't drive +// the (per-province) city query, so the cascade starts fresh instead of showing a dead value. +function initialRegion(initial?: AddressFormInitial): CascadingRegionValue { + if (initial?.provinceId == null) return { provinceId: null, cityId: null, districtId: null }; + return { provinceId: initial.provinceId, cityId: initial.cityId ?? null, districtId: initial.districtId ?? null }; +} + +function initialPin(initial?: AddressFormInitial): LatLng | null { + return initial?.latitude != null && initial?.longitude != null + ? { latitude: initial.latitude, longitude: initial.longitude } + : null; +} + +/** + * The add/edit address form body — the cascading region dropdowns + the map-pin picker + a + * title and street address + a "set as primary" toggle. Validation: **city required**, **pin + * required** (surfaced inline), title + street required, **district optional**. Emits a + * `CreateAddressInput` carrying the picked coordinates. Reused for create and edit. + * @component AddressForm + */ +const AddressForm: FunctionComponent = ({ initial, submitting = false, onSubmit, onCancel }) => { + const t = useTranslations('address'); + const tc = useTranslations('common'); + + const [title, setTitle] = useState(initial?.title ?? ''); + const [region, setRegion] = useState(() => initialRegion(initial)); + const [addressLine, setAddressLine] = useState(initial?.addressLine ?? ''); + const [pin, setPin] = useState(() => initialPin(initial)); + const [isPrimary, setIsPrimary] = useState(initial?.isPrimary ?? false); + + const [titleError, setTitleError] = useState(false); + const [cityError, setCityError] = useState(false); + const [lineError, setLineError] = useState(false); + const [pinError, setPinError] = useState(false); + + const handleSubmit = () => { + const titleInvalid = title.trim().length === 0; + const cityInvalid = region.cityId == null; + const lineInvalid = addressLine.trim().length === 0; + const pinInvalid = pin == null; + + setTitleError(titleInvalid); + setCityError(cityInvalid); + setLineError(lineInvalid); + setPinError(pinInvalid); + if (titleInvalid || cityInvalid || lineInvalid || pinInvalid) return; + + onSubmit({ + title: title.trim(), + provinceId: region.provinceId as number, + cityId: region.cityId as number, + districtId: region.districtId, + addressLine: addressLine.trim(), + latitude: pin!.latitude, + longitude: pin!.longitude, + isPrimary, + }); + }; + + return ( + + { + setTitle(event.target.value); + if (titleError) setTitleError(false); + }} + error={titleError} + helperText={titleError ? t('title_required') : undefined} + fullWidth + /> + + { + setRegion(next); + if (cityError && next.cityId != null) setCityError(false); + }} + cityError={cityError} + cityErrorText={t('city_required')} + /> + + { + setPin(next); + if (pinError) setPinError(false); + }} + center={cityCentroid(region.cityId)} + helperText={t('map_hint')} + latLabel={t('map_lat')} + lngLabel={t('map_lng')} + error={pinError} + errorText={t('map_required')} + /> + + { + setAddressLine(event.target.value); + if (lineError) setLineError(false); + }} + error={lineError} + helperText={lineError ? t('line_required') : t('line_hint')} + multiline + minRows={2} + fullWidth + /> + + setIsPrimary(event.target.checked)} />} + label={t('set_primary_toggle')} + /> + + + {onCancel ? ( + + {tc('cancel')} + + ) : null} + + {submitting ? tc('saving') : tc('save')} + + + + ); +}; + +export default AddressForm; diff --git a/client/src/components/geography/AddressMapPicker.test.tsx b/client/src/components/geography/AddressMapPicker.test.tsx new file mode 100644 index 0000000..32f275a --- /dev/null +++ b/client/src/components/geography/AddressMapPicker.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import AddressMapPicker from './AddressMapPicker'; +import type { LatLng } from '@/services/addresses/types'; + +function renderPicker(value: LatLng | null, center?: LatLng) { + const onChange = jest.fn(); + const utils = render( + + + , + ); + return { ...utils, onChange }; +} + +describe(' component', () => { + it('prompts to drop a pin when no coordinate is set', () => { + renderPicker(null); + expect(screen.getAllByText('Drop a pin').length).toBeGreaterThan(0); + }); + + it('emits real coordinates around the centre when the canvas is tapped', () => { + const center = { latitude: 35.6892, longitude: 51.389 }; + const { onChange } = renderPicker(null, center); + fireEvent.click(screen.getByRole('application'), { clientX: 10, clientY: 10 }); + // jsdom reports a 0×0 rect, so the tap resolves to the viewport centre = the city centroid. + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(center); + }); + + it('shows the coordinate readout once a pin is placed', () => { + renderPicker({ latitude: 35.6892, longitude: 51.389 }); + expect(screen.getByText(/35\.68920/)).toBeInTheDocument(); + expect(screen.getByText(/51\.38900/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/geography/AddressMapPicker.tsx b/client/src/components/geography/AddressMapPicker.tsx new file mode 100644 index 0000000..4af026a --- /dev/null +++ b/client/src/components/geography/AddressMapPicker.tsx @@ -0,0 +1,157 @@ +'use client'; +import { FunctionComponent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIcon from '@/components/common/AppIcon'; +import { IRAN_CENTROID } from '@/services/geography/constants'; +import type { LatLng } from '@/services/addresses/types'; + +export interface AddressMapPickerProps { + value: LatLng | null; + onChange: (value: LatLng) => void; + /** Centre of the picker's viewport — the chosen city's centroid, or the Iran centroid. */ + center?: LatLng; + helperText: string; + latLabel: string; + lngLabel: string; + error?: boolean; + errorText?: string; +} + +// Half-degree span each side of the centre (~±6 km) — enough precision for the later EVV check +// while keeping the whole stand-in viewport around one city. +const SPAN = 0.06; +const clamp01 = (n: number) => Math.min(1, Math.max(0, n)); +const round6 = (n: number) => Math.round(n * 1e6) / 1e6; + +/** + * Lightweight **map-pin picker stand-in** — a draggable/tappable marker panel that emits real + * `{ latitude, longitude }`. It is NOT a real map (no Neshan/Google tiles), only a bounded + * canvas mapping the pointer position to coordinates around the chosen city's centroid, behind a + * small component boundary so a real map drops in later without touching the address form. The + * picked coordinates are what the create/update request sends; the pin only refines coordinates — + * the bookable geography is still the region dropdown choice. + * @component AddressMapPicker + */ +const AddressMapPicker: FunctionComponent = ({ + value, + onChange, + center = IRAN_CENTROID, + helperText, + latLabel, + lngLabel, + error = false, + errorText, +}) => { + const mapRef = useRef(null); + const [dragging, setDragging] = useState(false); + + // Fraction [0,1] across the canvas → coordinates. x: west→east (left→right); y: north→south (top→bottom). + const fractionToLatLng = (fx: number, fy: number): LatLng => ({ + latitude: round6(center.latitude + SPAN - clamp01(fy) * 2 * SPAN), + longitude: round6(center.longitude - SPAN + clamp01(fx) * 2 * SPAN), + }); + + // Inverse — coordinates → percent offsets for the marker, plus the centering transform. All applied + // via inline `style` (not `sx`) so the RTL stylis plugin can't mirror them: it flips physical `left` + // AND inverts the X of `transform: translate(...)`, either of which would offset the pin from its + // click point on the default (fa/RTL) locale. Inline style bypasses the emotion cache entirely. + const markerStyle = (v: LatLng) => ({ + left: `${clamp01((v.longitude - (center.longitude - SPAN)) / (2 * SPAN)) * 100}%`, + top: `${clamp01((center.latitude + SPAN - v.latitude) / (2 * SPAN)) * 100}%`, + transform: 'translate(-50%, -100%)', + }); + + const place = (clientX: number, clientY: number) => { + const rect = mapRef.current?.getBoundingClientRect(); + // jsdom / zero-size layouts report a 0×0 rect — fall back to the centre so a tap still resolves. + const fx = rect && rect.width ? (clientX - rect.left) / rect.width : 0.5; + const fy = rect && rect.height ? (clientY - rect.top) / rect.height : 0.5; + onChange(fractionToLatLng(fx, fy)); + }; + + // A tap places via `click` (reliable everywhere); a drag places via pointer moves while held. + const handleClick = (event: ReactMouseEvent) => place(event.clientX, event.clientY); + const handlePointerDown = () => setDragging(true); + const handlePointerMove = (event: ReactPointerEvent) => { + if (dragging) place(event.clientX, event.clientY); + }; + const stopDragging = () => setDragging(false); + + return ( + + + {value ? ( + + + + + + ) : ( + + + + {helperText} + + + )} + + + {error && errorText ? ( + + {errorText} + + ) : ( + + {helperText} + + )} + + {value ? ( + + + {latLabel}: {value.latitude.toFixed(5)} + + + {lngLabel}: {value.longitude.toFixed(5)} + + + ) : null} + + ); +}; + +export default AddressMapPicker; diff --git a/client/src/components/geography/CascadingRegionSelect.test.tsx b/client/src/components/geography/CascadingRegionSelect.test.tsx new file mode 100644 index 0000000..c7961d4 --- /dev/null +++ b/client/src/components/geography/CascadingRegionSelect.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; + +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +const PROVINCES = [ + { id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }, + { id: 4, nameFa: 'فارس', nameEn: 'Fars', sortOrder: 3 }, +]; +const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }]; +const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }]; + +// Mock the aggressively-cached geography hooks so the cascade renders deterministically: +// city 101 has districts; any other city is whole-city-only (fetched-but-empty). +jest.mock('@/services/geography', () => ({ + useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }), + useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }), + useDistricts: (cityId: number | null) => ({ + data: cityId === 101 ? DISTRICTS : [], + isLoading: false, + isSuccess: cityId != null, + }), +})); + +import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect'; + +const NONE: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; + +function renderSelect(value: CascadingRegionValue) { + const onChange = jest.fn(); + const utils = render( + + + , + ); + return { ...utils, onChange }; +} + +describe(' component', () => { + it('lists provinces and blocks the city level until a province is chosen', () => { + renderSelect(NONE); + expect(screen.getByRole('combobox', { name: 'province' })).toBeInTheDocument(); + // City is not selectable yet — its helper prompts to pick a province first. + expect(screen.getByText('city_needs_province')).toBeInTheDocument(); + }); + + it('resets the city/district when the province changes', async () => { + const user = userEvent.setup(); + const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: 1001 }); + await user.click(screen.getByRole('combobox', { name: 'province' })); + await user.click(screen.getByRole('option', { name: 'Fars' })); + expect(onChange).toHaveBeenCalledWith({ provinceId: 4, cityId: null, districtId: null }); + }); + + it('offers the whole-city option plus districts for a city that has them', async () => { + const user = userEvent.setup(); + const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: null }); + await user.click(screen.getByRole('combobox', { name: 'district' })); + expect(screen.getByRole('option', { name: 'whole_city' })).toBeInTheDocument(); + await user.click(screen.getByRole('option', { name: 'District 1' })); + expect(onChange).toHaveBeenCalledWith({ provinceId: 1, cityId: 101, districtId: 1001 }); + }); + + it('surfaces the whole-city affordance for a city with no districts', () => { + renderSelect({ provinceId: 1, cityId: 201, districtId: null }); + expect(screen.getByText('no_districts')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/geography/CascadingRegionSelect.tsx b/client/src/components/geography/CascadingRegionSelect.tsx new file mode 100644 index 0000000..28b9c38 --- /dev/null +++ b/client/src/components/geography/CascadingRegionSelect.tsx @@ -0,0 +1,164 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import CircularProgress from '@mui/material/CircularProgress'; +import InputAdornment from '@mui/material/InputAdornment'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { useProvinces, useCities, useDistricts } from '@/services/geography'; +import { pickRegionName } from '@/services/geography/names'; + +/** The region a caller reads back — a `null` id means "not chosen yet"; a `null` district also + * doubles as the deliberate "whole city" choice once a city is set. */ +export interface CascadingRegionValue { + provinceId: number | null; + cityId: number | null; + districtId: number | null; +} + +export interface CascadingRegionSelectProps { + value: CascadingRegionValue; + onChange: (next: CascadingRegionValue) => void; + /** Render the district level. Off for a whole-city-only context (e.g. the coverage "whole city" scope). */ + includeDistrict?: boolean; + cityError?: boolean; + cityErrorText?: string; + districtError?: boolean; + districtErrorText?: string; + disabled?: boolean; +} + +const toId = (raw: string): number | null => (raw === '' ? null : Number(raw)); + +/** + * Province → city → district cascading dropdowns, driving the aggressively-cached geography + * queries itself so both the address form and the coverage editor drop it in with only a + * `value`/`onChange`. Each level enables only once its parent is chosen and resets its children + * on change; inactive regions never arrive (the server filters them), and a city with no + * districts surfaces the **whole-city** affordance rather than an error. **City is required; + * district is optional** — leaving district empty is a real choice, never an error. + * @component CascadingRegionSelect + */ +const CascadingRegionSelect: FunctionComponent = ({ + value, + onChange, + includeDistrict = true, + cityError = false, + cityErrorText, + districtError = false, + districtErrorText, + disabled = false, +}) => { + const t = useTranslations('geo'); + const locale = useLocale(); + + const provincesQuery = useProvinces(); + const citiesQuery = useCities(value.provinceId); + const districtsQuery = useDistricts(value.cityId); + + const provinces = provincesQuery.data ?? []; + const cities = citiesQuery.data ?? []; + const districts = districtsQuery.data ?? []; + + const hasProvince = value.provinceId != null; + const hasCity = value.cityId != null; + // Distinguish "not fetched yet" from "fetched and genuinely empty" (whole-city-only city). + const cityHasNoDistricts = hasCity && districtsQuery.isSuccess && districts.length === 0; + + // Only bind a value the loaded options actually contain — an id whose options are still + // fetching (e.g. an edit prefill) would otherwise trip MUI's out-of-range Select warning. + const provinceValue = provinces.some((province) => province.id === value.provinceId) ? value.provinceId : ''; + const cityValue = cities.some((city) => city.id === value.cityId) ? value.cityId : ''; + const districtValue = districts.some((district) => district.id === value.districtId) ? value.districtId : ''; + + const loadingAdornment = (loading: boolean) => + loading + ? { + endAdornment: ( + + + + ), + } + : undefined; + + const handleProvince = (raw: string) => + onChange({ provinceId: toId(raw), cityId: null, districtId: null }); + + const handleCity = (raw: string) => + onChange({ provinceId: value.provinceId, cityId: toId(raw), districtId: null }); + + const handleDistrict = (raw: string) => + onChange({ provinceId: value.provinceId, cityId: value.cityId, districtId: toId(raw) }); + + return ( + + handleProvince(event.target.value)} + disabled={disabled || provincesQuery.isLoading} + slotProps={{ input: loadingAdornment(provincesQuery.isLoading) }} + fullWidth + > + {provinces.map((province) => ( + + {pickRegionName(province, locale)} + + ))} + + + handleCity(event.target.value)} + disabled={disabled || !hasProvince || citiesQuery.isLoading} + error={cityError} + helperText={cityError ? cityErrorText : !hasProvince ? t('city_needs_province') : undefined} + slotProps={{ input: loadingAdornment(citiesQuery.isLoading) }} + fullWidth + > + {cities.map((city) => ( + + {pickRegionName(city, locale)} + + ))} + + + {includeDistrict ? ( + handleDistrict(event.target.value)} + disabled={disabled || !hasCity || districtsQuery.isLoading || cityHasNoDistricts} + error={districtError} + helperText={ + districtError + ? districtErrorText + : !hasCity + ? t('district_needs_city') + : cityHasNoDistricts + ? t('no_districts') + : t('district_optional') + } + slotProps={{ input: loadingAdornment(districtsQuery.isLoading) }} + fullWidth + > + {/* The empty option is the explicit "whole city" choice — district is optional. */} + {t('whole_city')} + {districts.map((district) => ( + + {pickRegionName(district, locale)} + + ))} + + ) : null} + + ); +}; + +export default CascadingRegionSelect; diff --git a/client/src/components/geography/index.ts b/client/src/components/geography/index.ts new file mode 100644 index 0000000..3d5e297 --- /dev/null +++ b/client/src/components/geography/index.ts @@ -0,0 +1,10 @@ +import CascadingRegionSelect from './CascadingRegionSelect'; +import AddressMapPicker from './AddressMapPicker'; +import AddressForm from './AddressForm'; +import AddressCard from './AddressCard'; + +export { CascadingRegionSelect, AddressMapPicker, AddressForm, AddressCard }; +export type { CascadingRegionSelectProps, CascadingRegionValue } from './CascadingRegionSelect'; +export type { AddressMapPickerProps } from './AddressMapPicker'; +export type { AddressFormProps, AddressFormInitial } from './AddressForm'; +export type { AddressCardProps } from './AddressCard'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 191581e..91a07a8 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -9,12 +9,16 @@ export const ROUTES = { ONBOARDING: '/onboarding', BOOKINGS: '/bookings', PATIENTS: '/patients', + // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. + ADDRESSES: '/addresses', WALLET: '/wallet', PROFILE: '/profile', // Nurse app NURSE: '/nurse', NURSE_PROFILE: '/nurse/profile', + // Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search). + NURSE_COVERAGE: '/nurse/coverage', NURSE_BANK: '/nurse/bank', NURSE_VERIFICATION: '/nurse/verification', NURSE_VISITS: '/nurse/visits', diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index a76ed91..06e4e58 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -19,6 +19,7 @@ const NurseLayout: FunctionComponent = ({ children }) => { () => [ { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' }, + { title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' }, { title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' }, { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, diff --git a/client/src/services/addresses/apis/clientApi.ts b/client/src/services/addresses/apis/clientApi.ts new file mode 100644 index 0000000..722a6b9 --- /dev/null +++ b/client/src/services/addresses/apis/clientApi.ts @@ -0,0 +1,80 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { ADDRESSES_PAGE_SIZE } from '../constants'; +import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, AddressesApi } from '../types'; + +const BASE = '/api/v1/customer_addresses'; + +// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes +// echo the caller's chosen province onto the returned row so the just-saved address can be +// re-edited with its cascade prefilled (not yet persisted server-side). +function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress { + return { ...dto, provinceId: provinceId ?? null }; +} + +// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 — +// the server geocodes today; sending the pin makes the coordinate the user's once accepted); +// `provinceId` stays client-side (city implies province server-side). +function toBody(input: CreateAddressInput) { + return { + title: input.title, + cityId: input.cityId, + districtId: input.districtId ?? null, + addressLine: input.addressLine, + postalCode: input.postalCode ?? null, + recipientName: input.recipientName ?? null, + recipientPhone: input.recipientPhone ?? null, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + isPrimary: input.isPrimary ?? false, + }; +} + +/** + * Real HTTP implementation of the AddressesApi seam (b4 action-style routes). `clientFetch` + * returns the raw envelope, so each call reads its payload via `unwrap`. Selected once + * USE_ADDRESSES_MOCK is false and REQ-008/REQ-009 land. + */ +export const addressesClientApi: AddressesApi = { + list: async (params) => { + const query = new URLSearchParams(); + query.set('page', String(params?.page ?? 1)); + // Pagination params bind case-insensitively to the server's `PageSize` (not snake_cased, unlike the + // geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`. + query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE)); + const page = unwrap( + await clientFetch>>(`${BASE}/list?${query.toString()}`), + ); + return { ...page, items: page.items.map((dto) => toAddress(dto)) }; + }, + + create: async (input) => + toAddress( + unwrap( + await clientFetch>(`${BASE}/create`, { + method: 'POST', + body: JSON.stringify(toBody(input)), + }), + ), + input.provinceId, + ), + + update: async (id, input) => + toAddress( + unwrap( + await clientFetch>(`${BASE}/update/${id}`, { + method: 'POST', + body: JSON.stringify(toBody(input)), + }), + ), + input.provinceId, + ), + + setPrimary: async (id) => { + await clientFetch>(`${BASE}/set_primary/${id}`, { method: 'POST' }); + }, + + remove: async (id) => { + await clientFetch>(`${BASE}/delete/${id}`, { method: 'DELETE' }); + }, +}; diff --git a/client/src/services/addresses/apis/index.ts b/client/src/services/addresses/apis/index.ts new file mode 100644 index 0000000..126f018 --- /dev/null +++ b/client/src/services/addresses/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_ADDRESSES_MOCK } from '../constants'; +import type { AddressesApi } from '../types'; +import { addressesClientApi } from './clientApi'; +import { addressesMockApi } from './mockApi'; + +/** + * The selected AddressesApi implementation — the single seam hooks import. Selection is by + * config (USE_ADDRESSES_MOCK), never by scattered `if (mock)` checks. + */ +export const addressesApi: AddressesApi = USE_ADDRESSES_MOCK ? addressesMockApi : addressesClientApi; diff --git a/client/src/services/addresses/apis/mockApi.ts b/client/src/services/addresses/apis/mockApi.ts new file mode 100644 index 0000000..a3d8f32 --- /dev/null +++ b/client/src/services/addresses/apis/mockApi.ts @@ -0,0 +1,98 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { PageParams, Paginated } from '@/lib/api/types'; +import { resolveSeedCity, resolveSeedDistrict } from '@/services/geography/apis/seed'; +import { ADDRESSES_PAGE_SIZE } from '../constants'; +import type { CreateAddressInput, CustomerAddress, AddressesApi } from '../types'; + +const MOCK_LATENCY_MS = 350; + +// In-memory store, seeded **empty** so a session can demo both the add flow and the E-style +// empty state. The single-primary invariant is enforced here exactly as the server's filtered +// unique index does: exactly one primary while ≥1 address exists. +let store: CustomerAddress[] = []; +let nextId = 1; + +// Denormalise the chosen region ids into the display names the DTO carries (the server joins +// these). A whole-city address (no district) keeps null district names. +function build(id: number, input: CreateAddressInput, isPrimary: boolean): CustomerAddress { + const city = resolveSeedCity(input.cityId); + const district = input.districtId == null ? undefined : resolveSeedDistrict(input.districtId); + return { + id, + title: input.title, + provinceId: input.provinceId, + cityId: input.cityId, + cityNameFa: city?.nameFa ?? '', + cityNameEn: city?.nameEn ?? '', + districtId: input.districtId ?? null, + districtNameFa: district?.nameFa ?? null, + districtNameEn: district?.nameEn ?? null, + addressLine: input.addressLine, + postalCode: input.postalCode ?? null, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + isPrimary, + recipientName: input.recipientName ?? null, + recipientPhone: input.recipientPhone ?? null, + }; +} + +const clearPrimaryExcept = (id: number) => + store.map((address) => (address.id === id ? address : { ...address, isPrimary: false })); + +// Primary first, then most-recent — mirrors the contract's "primary first" list order. +const ordered = () => [...store].sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || b.id - a.id); + +/** + * In-memory mock behind the AddressesApi seam — the b4 endpoints exist, but the wire DTO lacks + * `provinceId` (REQ-009) and the create body geocodes instead of accepting the pin (REQ-008), + * so this drives the UI until those land. Mirrors the real shapes for a one-line swap. + */ +export const addressesMockApi: AddressesApi = { + list: async (params?: PageParams): Promise> => { + await sleep(MOCK_LATENCY_MS); + const all = ordered(); + const page = params?.page ?? 1; + const pageSize = params?.pageSize ?? ADDRESSES_PAGE_SIZE; + const start = (page - 1) * pageSize; + return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize }; + }, + + create: async (input: CreateAddressInput): Promise => { + await sleep(MOCK_LATENCY_MS); + const becomesPrimary = store.length === 0 || input.isPrimary === true; + const address = build(nextId++, input, becomesPrimary); + store = becomesPrimary ? [address, ...clearPrimaryExcept(address.id)] : [address, ...store]; + return address; + }, + + update: async (id: number, input: CreateAddressInput): Promise => { + await sleep(MOCK_LATENCY_MS); + const existing = store.find((address) => address.id === id); + if (!existing) throw new ApiError(404, 'Address not found'); + // Editing never removes the sole primary: keep the prior flag unless the form promotes it. + const isPrimary = input.isPrimary === true || existing.isPrimary; + const updated = build(id, input, isPrimary); + store = store.map((address) => (address.id === id ? updated : address)); + if (isPrimary) store = clearPrimaryExcept(id); + return updated; + }, + + setPrimary: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + if (!store.some((address) => address.id === id)) throw new ApiError(404, 'Address not found'); + store = clearPrimaryExcept(id).map((address) => (address.id === id ? { ...address, isPrimary: true } : address)); + }, + + remove: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const removed = store.find((address) => address.id === id); + store = store.filter((address) => address.id !== id); + // Keep exactly one primary: if the deleted row was primary, promote the next in order. + if (removed?.isPrimary && store.length > 0) { + const next = ordered()[0].id; + store = store.map((address) => ({ ...address, isPrimary: address.id === next })); + } + }, +}; diff --git a/client/src/services/addresses/constants.ts b/client/src/services/addresses/constants.ts new file mode 100644 index 0000000..9b4dcb4 --- /dev/null +++ b/client/src/services/addresses/constants.ts @@ -0,0 +1,16 @@ +/** + * When true, the addresses domain is served by the in-memory mock (apis/mockApi.ts) behind the + * AddressesApi seam — single-primary + first-address-primary enforced in-memory so the address + * book demos before backend-phase-4 is reachable. The wire `CustomerAddressDto` also lacks + * `provinceId` (REQ-009) and its create body geocodes rather than accepting the pin (REQ-008), + * so this phase drives the UI behind the mock. Flip to false to use the live + * `api/v1/customer_addresses/*` routes — no hook/component changes + * (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_ADDRESSES_MOCK = true; + +/** Address lists change only on mutation; keep them warm across screen visits. */ +export const ADDRESSES_STALE_TIME = 60_000; + +/** api-conventions default page size; `pageSize` ≤ 100. A customer has a handful of addresses. */ +export const ADDRESSES_PAGE_SIZE = 50; diff --git a/client/src/services/addresses/hooks/useAddresses.ts b/client/src/services/addresses/hooks/useAddresses.ts new file mode 100644 index 0000000..466c2e6 --- /dev/null +++ b/client/src/services/addresses/hooks/useAddresses.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import type { PageParams } from '@/lib/api/types'; +import { addressesApi } from '../apis'; +import { addressKeys } from '../keys'; +import { ADDRESSES_STALE_TIME } from '../constants'; + +/** + * The customer's addresses (primary first). A deliberate staleTime keeps the book warm across + * remounts; every mutation invalidates it, so it always reflects reality without over-fetching. + */ +export function useAddresses(params?: PageParams) { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: addressKeys.list(params), + queryFn: () => addressesApi.list(params), + enabled: isAuthenticated, + staleTime: ADDRESSES_STALE_TIME, + }); +} diff --git a/client/src/services/addresses/hooks/useCreateAddress.ts b/client/src/services/addresses/hooks/useCreateAddress.ts new file mode 100644 index 0000000..f58ace1 --- /dev/null +++ b/client/src/services/addresses/hooks/useCreateAddress.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addressesApi } from '../apis'; +import { addressKeys } from '../keys'; +import type { CreateAddressInput } from '../types'; + +/** + * Creates an address. Invalidates every cached list so the new card (and, if it became primary, + * the flipped badge on the old one) reflects reality. Domain 400s (empty title/addressLine, + * invalid city) surface via `mutation.error`; the fetch layer owns 401/403/5xx toasts. + */ +export function useCreateAddress() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateAddressInput) => addressesApi.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: addressKeys.lists() }); + }, + }); +} diff --git a/client/src/services/addresses/hooks/useDeleteAddress.ts b/client/src/services/addresses/hooks/useDeleteAddress.ts new file mode 100644 index 0000000..c7ab49a --- /dev/null +++ b/client/src/services/addresses/hooks/useDeleteAddress.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addressesApi } from '../apis'; +import { addressKeys } from '../keys'; + +/** Soft-deletes an owned address, then invalidates the lists. */ +export function useDeleteAddress() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => addressesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: addressKeys.lists() }); + }, + }); +} diff --git a/client/src/services/addresses/hooks/useSetPrimaryAddress.ts b/client/src/services/addresses/hooks/useSetPrimaryAddress.ts new file mode 100644 index 0000000..37b6c0b --- /dev/null +++ b/client/src/services/addresses/hooks/useSetPrimaryAddress.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addressesApi } from '../apis'; +import { addressKeys } from '../keys'; + +/** + * Makes an address the customer's primary; single-primary enforcement is server-side (the prior + * primary is cleared atomically). Invalidates the list — never hand-patches — so exactly one + * card shows the badge after the flip. + */ +export function useSetPrimaryAddress() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => addressesApi.setPrimary(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: addressKeys.lists() }); + }, + }); +} diff --git a/client/src/services/addresses/hooks/useUpdateAddress.ts b/client/src/services/addresses/hooks/useUpdateAddress.ts new file mode 100644 index 0000000..d0a5871 --- /dev/null +++ b/client/src/services/addresses/hooks/useUpdateAddress.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addressesApi } from '../apis'; +import { addressKeys } from '../keys'; +import type { UpdateAddressInput } from '../types'; + +/** Edits an owned address, then invalidates the lists so the card reflects the change. */ +export function useUpdateAddress() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateAddressInput }) => addressesApi.update(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: addressKeys.lists() }); + }, + }); +} diff --git a/client/src/services/addresses/index.ts b/client/src/services/addresses/index.ts new file mode 100644 index 0000000..b0c1daf --- /dev/null +++ b/client/src/services/addresses/index.ts @@ -0,0 +1,5 @@ +export { useAddresses } from './hooks/useAddresses'; +export { useCreateAddress } from './hooks/useCreateAddress'; +export { useUpdateAddress } from './hooks/useUpdateAddress'; +export { useDeleteAddress } from './hooks/useDeleteAddress'; +export { useSetPrimaryAddress } from './hooks/useSetPrimaryAddress'; diff --git a/client/src/services/addresses/keys.ts b/client/src/services/addresses/keys.ts new file mode 100644 index 0000000..3598fd5 --- /dev/null +++ b/client/src/services/addresses/keys.ts @@ -0,0 +1,14 @@ +import type { PageParams } from '@/lib/api/types'; + +/** + * React Query key factory for the addresses domain. Hierarchical keys let every mutation + * (create/update/delete/set-primary) invalidate all lists with `addressKeys.lists()` — the + * set-primary flip touches two rows, so we invalidate rather than hand-patch. + */ +export const addressKeys = { + all: ['addresses'] as const, + lists: () => [...addressKeys.all, 'list'] as const, + list: (params?: PageParams) => [...addressKeys.lists(), params ?? {}] as const, + details: () => [...addressKeys.all, 'detail'] as const, + detail: (id: number) => [...addressKeys.details(), id] as const, +}; diff --git a/client/src/services/addresses/types.ts b/client/src/services/addresses/types.ts new file mode 100644 index 0000000..eeb31ae --- /dev/null +++ b/client/src/services/addresses/types.ts @@ -0,0 +1,79 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Customer addresses domain — a customer's saved, geocoded delivery addresses, customer-scoped + * and tenancy-enforced server-side. Shapes mirror the b4 contract + * (`dev/contracts/domains/geography-addresses.md` → `CustomerAddressDto`); the wire is camelCase. + * + * PII (`addressLine`/`postalCode`/`recipientName`/`recipientPhone`) is encrypted at rest and + * **decrypted for the owner** on their own `list` — this screen only ever shows the owner their + * own book, so the full `addressLine` is rendered (no masking is needed here). + * + * `provinceId` is **client-augmented**: the wire `CustomerAddressDto` carries `cityId` but not + * its province (filed as REQ-009). We need the province to prefill the cascading dropdowns when + * editing, so the mock persists it and the real client echoes the form's choice onto the + * returned row (a genuinely refetched list from the server carries `null` until REQ-009 lands). + */ + +/** Coordinates from the map-pin picker (also what the server's geocoder resolves to). */ +export interface LatLng { + latitude: number; + longitude: number; +} + +/** The b4 wire shape returned by every `customer_addresses/*` endpoint. */ +export interface CustomerAddressDto { + id: number; + title: string; + cityId: number; + cityNameFa: string; + cityNameEn: string; + districtId: number | null; + districtNameFa: string | null; + districtNameEn: string | null; + /** Decrypted for the owner; `null` only if never set. */ + addressLine: string | null; + postalCode: string | null; + /** `null` when the geocoder couldn't resolve the address ("no map pin"). */ + latitude: number | null; + longitude: number | null; + isPrimary: boolean; + recipientName: string | null; + recipientPhone: string | null; +} + +/** App-level address = wire shape + the client-augmented `provinceId` (REQ-009). */ +export interface CustomerAddress extends CustomerAddressDto { + provinceId: number | null; +} + +/** + * Create/update input. `cityId` is required; `districtId` omitted/`null` = the whole city. + * `latitude`/`longitude` are the picked pin (REQ-008 — the contract create body geocodes + * server-side today; we send the pin so the coordinate is the user's, and echo it locally). + * `provinceId` is client-augmented (REQ-009) — used to prefill the cascade on edit. + */ +export interface CreateAddressInput { + title: string; + provinceId: number; + cityId: number; + districtId?: number | null; + addressLine: string; + postalCode?: string | null; + recipientName?: string | null; + recipientPhone?: string | null; + latitude?: number | null; + longitude?: number | null; + isPrimary?: boolean; +} + +export type UpdateAddressInput = CreateAddressInput; + +/** The domain's API seam — a mock and the real client both implement this interface. */ +export interface AddressesApi { + list(params?: PageParams): Promise>; + create(input: CreateAddressInput): Promise; + update(id: number, input: UpdateAddressInput): Promise; + setPrimary(id: number): Promise; + remove(id: number): Promise; +} diff --git a/client/src/services/geography/apis/clientApi.ts b/client/src/services/geography/apis/clientApi.ts new file mode 100644 index 0000000..f45fbd9 --- /dev/null +++ b/client/src/services/geography/apis/clientApi.ts @@ -0,0 +1,22 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope } from '@/lib/api/types'; +import type { City, District, GeographyApi, Province } from '../types'; + +const BASE = '/api/v1/geo'; + +/** + * Real HTTP implementation of the GeographyApi seam (b4 public `geo/*` lookups, no auth). The + * server returns active-only, `sortOrder`-ordered rows, so the client does no re-filtering or + * re-sorting beyond what the contract guarantees. Query params are snake_case + * (`province_id`/`city_id`); the response bodies are camelCase. Selected once USE_GEOGRAPHY_MOCK + * is false. + */ +export const geographyClientApi: GeographyApi = { + listProvinces: async () => unwrap(await clientFetch>(`${BASE}/provinces`)), + + listCities: async (provinceId: number) => + unwrap(await clientFetch>(`${BASE}/cities?province_id=${provinceId}`)), + + listDistricts: async (cityId: number) => + unwrap(await clientFetch>(`${BASE}/districts?city_id=${cityId}`)), +}; diff --git a/client/src/services/geography/apis/index.ts b/client/src/services/geography/apis/index.ts new file mode 100644 index 0000000..dfa843a --- /dev/null +++ b/client/src/services/geography/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_GEOGRAPHY_MOCK } from '../constants'; +import type { GeographyApi } from '../types'; +import { geographyClientApi } from './clientApi'; +import { geographyMockApi } from './mockApi'; + +/** + * The selected GeographyApi implementation — the single seam the hooks import. Selection is by + * config (USE_GEOGRAPHY_MOCK), never by scattered `if (mock)` checks. + */ +export const geographyApi: GeographyApi = USE_GEOGRAPHY_MOCK ? geographyMockApi : geographyClientApi; diff --git a/client/src/services/geography/apis/mockApi.ts b/client/src/services/geography/apis/mockApi.ts new file mode 100644 index 0000000..355af5c --- /dev/null +++ b/client/src/services/geography/apis/mockApi.ts @@ -0,0 +1,29 @@ +import { sleep } from '@/utils'; +import type { City, District, GeographyApi, Province } from '../types'; +import { SEED_CITIES, SEED_DISTRICTS, SEED_PROVINCES } from './seed'; + +const MOCK_LATENCY_MS = 250; + +const bySortOrder = (a: T, b: T) => a.sortOrder - b.sortOrder; + +/** + * In-memory mock behind the GeographyApi seam — returns the canned, active-only, + * `sortOrder`-ordered hierarchy. Mirrors the real lookups exactly (empty district list for a + * whole-city-only city), so swapping to the live endpoints is a one-line change in constants.ts. + */ +export const geographyMockApi: GeographyApi = { + listProvinces: async (): Promise => { + await sleep(MOCK_LATENCY_MS); + return [...SEED_PROVINCES].sort(bySortOrder); + }, + + listCities: async (provinceId: number): Promise => { + await sleep(MOCK_LATENCY_MS); + return SEED_CITIES.filter((city) => city.provinceId === provinceId).sort(bySortOrder); + }, + + listDistricts: async (cityId: number): Promise => { + await sleep(MOCK_LATENCY_MS); + return SEED_DISTRICTS.filter((district) => district.cityId === cityId).sort(bySortOrder); + }, +}; diff --git a/client/src/services/geography/apis/seed.ts b/client/src/services/geography/apis/seed.ts new file mode 100644 index 0000000..6b3dbba --- /dev/null +++ b/client/src/services/geography/apis/seed.ts @@ -0,0 +1,62 @@ +import type { City, District, Province } from '../types'; + +/** + * Canned geography hierarchy for the client-side mock — a faithful subset of the b4 seed so the + * cascade, the address book, and the coverage editor all demo before backend-phase-4 is + * reachable. Ids mirror the contract where fixed (Tehran province 1 / city 101 / districts + * 1001…1022) and are assigned deterministically for the other seeded capital cities. Every row + * here is active; the mock never returns inactive regions (the real server filters them out). + * + * Shared by the geography mock (list endpoints) and the addresses/serviceAreas mocks (which + * resolve a saved `cityId`/`districtId` back to its display names). Real data comes from the + * server; this file is mock-only and imported nowhere in the production path. + */ + +const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; +const toFaDigits = (n: number): string => String(n).replace(/\d/g, (d) => FA_DIGITS[Number(d)]); + +// White-space second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom) the coverage +// model must serve, plus Tehran (the only seeded city with districts) and Karaj. +export const SEED_PROVINCES: Province[] = [ + { id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }, + { id: 2, nameFa: 'خراسان رضوی', nameEn: 'Razavi Khorasan', sortOrder: 1 }, + { id: 3, nameFa: 'اصفهان', nameEn: 'Isfahan', sortOrder: 2 }, + { id: 4, nameFa: 'فارس', nameEn: 'Fars', sortOrder: 3 }, + { id: 5, nameFa: 'آذربایجان شرقی', nameEn: 'East Azerbaijan', sortOrder: 4 }, + { id: 6, nameFa: 'خوزستان', nameEn: 'Khuzestan', sortOrder: 5 }, + { id: 7, nameFa: 'قم', nameEn: 'Qom', sortOrder: 6 }, + { id: 8, nameFa: 'البرز', nameEn: 'Alborz', sortOrder: 7 }, +]; + +export const SEED_CITIES: City[] = [ + { id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }, + { id: 201, provinceId: 2, nameFa: 'مشهد', nameEn: 'Mashhad', sortOrder: 0 }, + { id: 301, provinceId: 3, nameFa: 'اصفهان', nameEn: 'Isfahan', sortOrder: 0 }, + { id: 401, provinceId: 4, nameFa: 'شیراز', nameEn: 'Shiraz', sortOrder: 0 }, + { id: 501, provinceId: 5, nameFa: 'تبریز', nameEn: 'Tabriz', sortOrder: 0 }, + { id: 601, provinceId: 6, nameFa: 'اهواز', nameEn: 'Ahvaz', sortOrder: 0 }, + { id: 701, provinceId: 7, nameFa: 'قم', nameEn: 'Qom', sortOrder: 0 }, + { id: 801, provinceId: 8, nameFa: 'کرج', nameEn: 'Karaj', sortOrder: 0 }, +]; + +// Tehran's 22 مناطق (1001…1022); every other seeded city is whole-city-only (no districts). +export const SEED_DISTRICTS: District[] = Array.from({ length: 22 }, (_, index) => { + const n = index + 1; + return { + id: 1000 + n, + cityId: 101, + nameFa: `منطقه ${toFaDigits(n)}`, + nameEn: `District ${n}`, + sortOrder: index, + }; +}); + +/** Resolve a saved city id back to its names + province (mock-only; for addresses/serviceAreas). */ +export function resolveSeedCity(cityId: number): City | undefined { + return SEED_CITIES.find((city) => city.id === cityId); +} + +/** Resolve a saved district id back to its names (mock-only; null district = whole city). */ +export function resolveSeedDistrict(districtId: number): District | undefined { + return SEED_DISTRICTS.find((district) => district.id === districtId); +} diff --git a/client/src/services/geography/constants.ts b/client/src/services/geography/constants.ts new file mode 100644 index 0000000..3edc037 --- /dev/null +++ b/client/src/services/geography/constants.ts @@ -0,0 +1,42 @@ +/** + * When true, the geo hierarchy is served by the in-memory mock (apis/mockApi.ts) behind the + * GeographyApi seam — canned provinces/cities/districts (incl. Tehran's 22 مناطق and the + * white-space second-tier cities) so the cascade demos before backend-phase-4 is reachable. + * Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes + * (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_GEOGRAPHY_MOCK = true; + +/** + * Reference data almost never changes, so it is cached **for the whole session**: an Infinite + * `staleTime` means a province/city's children are fetched once and served from cache on every + * revisit and across both editors (and later search). A generous `gcTime` keeps them warm even + * after the last consumer unmounts. This is the aggressive-caching rule the phase exists to set. + */ +export const GEO_STALE_TIME = Infinity; +export const GEO_GC_TIME = 24 * 60 * 60 * 1000; // 24h + +/** + * Approximate map-pin default centre per known city, keyed by the b4 seed city ids (Tehran = 101 + * matches the real seed; the other ids match apis/seed.ts). Used only to centre the map-pin + * picker on the chosen city; unknown cities (real data whose id we don't map) fall back to the + * Iran centroid. Not authoritative geography — the picker emits the user's real dropped coords. + */ +export const IRAN_CENTROID = { latitude: 32.4279, longitude: 53.688 } as const; + +export const CITY_CENTROIDS: Record = { + 101: { latitude: 35.6892, longitude: 51.389 }, // Tehran + 201: { latitude: 36.2605, longitude: 59.6168 }, // Mashhad + 301: { latitude: 32.6539, longitude: 51.666 }, // Isfahan + 401: { latitude: 29.5918, longitude: 52.5837 }, // Shiraz + 501: { latitude: 38.0797, longitude: 46.2919 }, // Tabriz + 601: { latitude: 31.3183, longitude: 48.6706 }, // Ahvaz + 701: { latitude: 34.6416, longitude: 50.8746 }, // Qom + 801: { latitude: 35.8327, longitude: 50.9916 }, // Karaj +}; + +/** The map-pin picker centre for a city — its centroid if known, else the Iran centroid. */ +export function cityCentroid(cityId?: number | null): { latitude: number; longitude: number } { + if (cityId != null && CITY_CENTROIDS[cityId]) return CITY_CENTROIDS[cityId]; + return IRAN_CENTROID; +} diff --git a/client/src/services/geography/hooks/useCities.ts b/client/src/services/geography/hooks/useCities.ts new file mode 100644 index 0000000..351057c --- /dev/null +++ b/client/src/services/geography/hooks/useCities.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { geographyApi } from '../apis'; +import { geographyKeys } from '../keys'; +import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants'; + +/** + * The active cities of a province (ordered). Enabled only once a province is selected; each + * province's cities are cached independently and forever within the session, so switching back + * to a previously-opened province never refetches. + */ +export function useCities(provinceId?: number | null) { + return useQuery({ + queryKey: geographyKeys.cities(provinceId), + queryFn: () => geographyApi.listCities(provinceId as number), + enabled: provinceId != null, + staleTime: GEO_STALE_TIME, + gcTime: GEO_GC_TIME, + }); +} diff --git a/client/src/services/geography/hooks/useDistricts.ts b/client/src/services/geography/hooks/useDistricts.ts new file mode 100644 index 0000000..f09635a --- /dev/null +++ b/client/src/services/geography/hooks/useDistricts.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { geographyApi } from '../apis'; +import { geographyKeys } from '../keys'; +import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants'; + +/** + * The active districts of a city (ordered). Enabled only once a city is selected. An **empty + * list is valid** — a whole-city-only city (e.g. Mashhad) has no districts, which the cascade + * surfaces as the "whole city" affordance rather than an error. + */ +export function useDistricts(cityId?: number | null) { + return useQuery({ + queryKey: geographyKeys.districts(cityId), + queryFn: () => geographyApi.listDistricts(cityId as number), + enabled: cityId != null, + staleTime: GEO_STALE_TIME, + gcTime: GEO_GC_TIME, + }); +} diff --git a/client/src/services/geography/hooks/useProvinces.ts b/client/src/services/geography/hooks/useProvinces.ts new file mode 100644 index 0000000..d2b56b6 --- /dev/null +++ b/client/src/services/geography/hooks/useProvinces.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { geographyApi } from '../apis'; +import { geographyKeys } from '../keys'; +import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants'; + +/** + * The active provinces (ordered). Reference data — cached for the whole session (Infinite + * `staleTime`), so the province dropdown is populated once and served from cache on every + * revisit and across both editors. + */ +export function useProvinces() { + return useQuery({ + queryKey: geographyKeys.provinces(), + queryFn: () => geographyApi.listProvinces(), + staleTime: GEO_STALE_TIME, + gcTime: GEO_GC_TIME, + }); +} diff --git a/client/src/services/geography/index.ts b/client/src/services/geography/index.ts new file mode 100644 index 0000000..acaf80e --- /dev/null +++ b/client/src/services/geography/index.ts @@ -0,0 +1,3 @@ +export { useProvinces } from './hooks/useProvinces'; +export { useCities } from './hooks/useCities'; +export { useDistricts } from './hooks/useDistricts'; diff --git a/client/src/services/geography/keys.ts b/client/src/services/geography/keys.ts new file mode 100644 index 0000000..782ed19 --- /dev/null +++ b/client/src/services/geography/keys.ts @@ -0,0 +1,12 @@ +/** + * React Query key factory for the geography reference-data domain. Hierarchical keys let a + * province's cities and a city's districts be cached independently and served from cache on + * every revisit (and shared across both editors + f6 search). Reference data is fetched once + * per session, so these keys are never invalidated by this phase. + */ +export const geographyKeys = { + all: ['geography'] as const, + provinces: () => [...geographyKeys.all, 'provinces'] as const, + cities: (provinceId?: number | null) => [...geographyKeys.all, 'cities', provinceId ?? null] as const, + districts: (cityId?: number | null) => [...geographyKeys.all, 'districts', cityId ?? null] as const, +}; diff --git a/client/src/services/geography/names.ts b/client/src/services/geography/names.ts new file mode 100644 index 0000000..56f72d7 --- /dev/null +++ b/client/src/services/geography/names.ts @@ -0,0 +1,10 @@ +import type { RegionName } from './types'; + +/** + * Picks the locale-appropriate name for a reference region. `fa` is the default locale; any + * non-`en` locale reads the Persian name. Kept here (not in a component) so the address book, + * the coverage editor, and search all label regions identically. + */ +export function pickRegionName(region: RegionName, locale: string): string { + return locale === 'en' ? region.nameEn : region.nameFa; +} diff --git a/client/src/services/geography/types.ts b/client/src/services/geography/types.ts new file mode 100644 index 0000000..b8d3729 --- /dev/null +++ b/client/src/services/geography/types.ts @@ -0,0 +1,44 @@ +/** + * Geography reference-data domain — the public province → city → district hierarchy the + * backend seeds and serves (b4 contract `dev/contracts/domains/geography-addresses.md`). + * Shapes mirror the wire DTOs exactly; the wire casing is **camelCase** + * (`nameFa`/`sortOrder`/`provinceId`), not the snake_case the routing convention implies — + * the swagger snapshot is the source of truth. + * + * The public lookups return **active-only, `sortOrder`-ordered** rows, so the DTOs carry no + * `isActive`: an inactive region is simply absent, never a disabled option to render. A city + * with **no districts** (e.g. Mashhad) returns a valid empty district list = whole-city-only. + */ + +/** Fields shared by every reference region — the localisable name pair drives the dropdown label. */ +export interface RegionName { + nameFa: string; + nameEn: string; +} + +/** `ProvinceDto`. */ +export interface Province extends RegionName { + id: number; + sortOrder: number; +} + +/** `CityDto` — belongs to one province. */ +export interface City extends RegionName { + id: number; + provinceId: number; + sortOrder: number; +} + +/** `DistrictDto` — belongs to one city. A `null`/absent district on a coverage area = whole city. */ +export interface District extends RegionName { + id: number; + cityId: number; + sortOrder: number; +} + +/** The domain's API seam — a mock and the real client both implement this interface. */ +export interface GeographyApi { + listProvinces(): Promise; + listCities(provinceId: number): Promise; + listDistricts(cityId: number): Promise; +} diff --git a/client/src/services/serviceAreas/apis/clientApi.ts b/client/src/services/serviceAreas/apis/clientApi.ts new file mode 100644 index 0000000..04dbb2e --- /dev/null +++ b/client/src/services/serviceAreas/apis/clientApi.ts @@ -0,0 +1,36 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { SERVICE_AREAS_PAGE_SIZE } from '../constants'; +import type { AddServiceAreaInput, NurseServiceArea, ServiceAreasApi } from '../types'; + +const BASE = '/api/v1/nurse_service_areas'; + +/** + * Real HTTP implementation of the ServiceAreasApi seam (b4 action-style routes). A duplicate + * `add` returns `409` — `clientFetch` throws `ApiError(409)` (no toast; "other 4xx"), which the + * add hook/screen maps to the inline "already covered" message. Selected once + * USE_SERVICE_AREAS_MOCK is false. + */ +export const serviceAreasClientApi: ServiceAreasApi = { + list: async (params) => { + const query = new URLSearchParams(); + query.set('page', String(params?.page ?? 1)); + // Pagination binds case-insensitively to the server's `PageSize` (not snake_cased); match the template. + query.set('pageSize', String(params?.pageSize ?? SERVICE_AREAS_PAGE_SIZE)); + return unwrap( + await clientFetch>>(`${BASE}/list?${query.toString()}`), + ); + }, + + add: async (input: AddServiceAreaInput) => + unwrap( + await clientFetch>(`${BASE}/add`, { + method: 'POST', + body: JSON.stringify({ cityId: input.cityId, districtId: input.districtId ?? null }), + }), + ), + + remove: async (id: number) => { + await clientFetch>(`${BASE}/remove/${id}`, { method: 'DELETE' }); + }, +}; diff --git a/client/src/services/serviceAreas/apis/index.ts b/client/src/services/serviceAreas/apis/index.ts new file mode 100644 index 0000000..7456e87 --- /dev/null +++ b/client/src/services/serviceAreas/apis/index.ts @@ -0,0 +1,12 @@ +import { USE_SERVICE_AREAS_MOCK } from '../constants'; +import type { ServiceAreasApi } from '../types'; +import { serviceAreasClientApi } from './clientApi'; +import { serviceAreasMockApi } from './mockApi'; + +/** + * The selected ServiceAreasApi implementation — the single seam hooks import. Selection is by + * config (USE_SERVICE_AREAS_MOCK), never by scattered `if (mock)` checks. + */ +export const serviceAreasApi: ServiceAreasApi = USE_SERVICE_AREAS_MOCK + ? serviceAreasMockApi + : serviceAreasClientApi; diff --git a/client/src/services/serviceAreas/apis/mockApi.ts b/client/src/services/serviceAreas/apis/mockApi.ts new file mode 100644 index 0000000..ffda1bf --- /dev/null +++ b/client/src/services/serviceAreas/apis/mockApi.ts @@ -0,0 +1,65 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { PageParams, Paginated } from '@/lib/api/types'; +import { resolveSeedCity, resolveSeedDistrict } from '@/services/geography/apis/seed'; +import { SERVICE_AREAS_PAGE_SIZE } from '../constants'; +import { areaExists, type AddServiceAreaInput, type NurseServiceArea, type ServiceAreasApi } from '../types'; + +const MOCK_LATENCY_MS = 300; + +// In-memory store, seeded **empty** so the "won't appear in search" empty-state warning demos. +let store: NurseServiceArea[] = []; +let nextId = 1; + +// Whole-city rows first (matching the contract's list order), then by most-recent add. +const ordered = () => + [...store].sort((a, b) => Number(b.isWholeCity) - Number(a.isWholeCity) || b.id - a.id); + +function build(id: number, input: AddServiceAreaInput): NurseServiceArea { + const city = resolveSeedCity(input.cityId); + const districtId = input.districtId ?? null; + const district = districtId == null ? undefined : resolveSeedDistrict(districtId); + return { + id, + cityId: input.cityId, + cityNameFa: city?.nameFa ?? '', + cityNameEn: city?.nameEn ?? '', + districtId, + districtNameFa: district?.nameFa ?? null, + districtNameEn: district?.nameEn ?? null, + isWholeCity: districtId == null, + isActive: true, + }; +} + +/** + * In-memory mock behind the ServiceAreasApi seam. Enforces the `UNIQUE(cityId, districtId)` + * rule exactly as the server does — a duplicate (including a second whole-city row) throws the + * same `409` the real endpoint returns — so the coverage editor's inline duplicate handling is + * demonstrable end-to-end. Mirrors the real shapes for a one-line swap. + */ +export const serviceAreasMockApi: ServiceAreasApi = { + list: async (params?: PageParams): Promise> => { + await sleep(MOCK_LATENCY_MS); + const all = ordered(); + const page = params?.page ?? 1; + const pageSize = params?.pageSize ?? SERVICE_AREAS_PAGE_SIZE; + const start = (page - 1) * pageSize; + return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize }; + }, + + add: async (input: AddServiceAreaInput): Promise => { + await sleep(MOCK_LATENCY_MS); + if (areaExists(store, input.cityId, input.districtId ?? null)) { + throw new ApiError(409, 'Area already covered', 'area_duplicate'); + } + const area = build(nextId++, input); + store = [area, ...store]; + return area; + }, + + remove: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + store = store.filter((area) => area.id !== id); + }, +}; diff --git a/client/src/services/serviceAreas/areaExists.test.ts b/client/src/services/serviceAreas/areaExists.test.ts new file mode 100644 index 0000000..05a7416 --- /dev/null +++ b/client/src/services/serviceAreas/areaExists.test.ts @@ -0,0 +1,33 @@ +import { areaExists, type NurseServiceArea } from './types'; + +const area = (id: number, cityId: number, districtId: number | null): NurseServiceArea => ({ + id, + cityId, + cityNameFa: '', + cityNameEn: '', + districtId, + districtNameFa: null, + districtNameEn: null, + isWholeCity: districtId == null, + isActive: true, +}); + +describe('areaExists — coverage-area duplicate guard', () => { + const areas = [area(1, 101, null), area(2, 101, 1001)]; + + it('detects a duplicate whole-city area (null district treated as a real value)', () => { + expect(areaExists(areas, 101, null)).toBe(true); + }); + + it('detects a duplicate city+district area', () => { + expect(areaExists(areas, 101, 1001)).toBe(true); + }); + + it('treats whole-city and a specific district in the same city as distinct', () => { + expect(areaExists([area(1, 101, null)], 101, 1002)).toBe(false); + }); + + it('is false for a city not yet covered', () => { + expect(areaExists(areas, 201, null)).toBe(false); + }); +}); diff --git a/client/src/services/serviceAreas/constants.ts b/client/src/services/serviceAreas/constants.ts new file mode 100644 index 0000000..e82b7a0 --- /dev/null +++ b/client/src/services/serviceAreas/constants.ts @@ -0,0 +1,14 @@ +/** + * When true, the nurse service-areas domain is served by the in-memory mock (apis/mockApi.ts) + * behind the ServiceAreasApi seam — the duplicate `(cityId, districtId)` 409 and whole-city-first + * ordering are enforced in-memory so the coverage editor demos before backend-phase-4 is + * reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no + * hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_SERVICE_AREAS_MOCK = true; + +/** Service areas change only on mutation; keep them warm across screen visits. */ +export const SERVICE_AREAS_STALE_TIME = 60_000; + +/** api-conventions default page size; `pageSize` ≤ 100. A nurse covers a handful of areas. */ +export const SERVICE_AREAS_PAGE_SIZE = 100; diff --git a/client/src/services/serviceAreas/hooks/useAddServiceArea.ts b/client/src/services/serviceAreas/hooks/useAddServiceArea.ts new file mode 100644 index 0000000..b4c4ad6 --- /dev/null +++ b/client/src/services/serviceAreas/hooks/useAddServiceArea.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { serviceAreasApi } from '../apis'; +import { serviceAreaKeys } from '../keys'; +import type { AddServiceAreaInput } from '../types'; + +/** + * Adds a coverage area, then invalidates the list. A duplicate `(cityId, districtId)` returns + * `409` — surfaced via `mutation.error` so the screen shows the inline "already covered" message + * (the client-side check is the fast path; this 409 is the belt-and-braces server truth). + */ +export function useAddServiceArea() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: AddServiceAreaInput) => serviceAreasApi.add(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: serviceAreaKeys.lists() }); + }, + }); +} diff --git a/client/src/services/serviceAreas/hooks/useRemoveServiceArea.ts b/client/src/services/serviceAreas/hooks/useRemoveServiceArea.ts new file mode 100644 index 0000000..1f24cdc --- /dev/null +++ b/client/src/services/serviceAreas/hooks/useRemoveServiceArea.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { serviceAreasApi } from '../apis'; +import { serviceAreaKeys } from '../keys'; + +/** Removes an owned coverage area, then invalidates the list. */ +export function useRemoveServiceArea() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => serviceAreasApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: serviceAreaKeys.lists() }); + }, + }); +} diff --git a/client/src/services/serviceAreas/hooks/useServiceAreas.ts b/client/src/services/serviceAreas/hooks/useServiceAreas.ts new file mode 100644 index 0000000..5d72056 --- /dev/null +++ b/client/src/services/serviceAreas/hooks/useServiceAreas.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import type { PageParams } from '@/lib/api/types'; +import { serviceAreasApi } from '../apis'; +import { serviceAreaKeys } from '../keys'; +import { SERVICE_AREAS_STALE_TIME } from '../constants'; + +/** + * The nurse's coverage areas (whole-city first). A deliberate staleTime keeps them warm across + * remounts; add/remove invalidate the list. The screen also reads this cache for the fast-path + * duplicate check before firing an add. + */ +export function useServiceAreas(params?: PageParams) { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: serviceAreaKeys.list(params), + queryFn: () => serviceAreasApi.list(params), + enabled: isAuthenticated, + staleTime: SERVICE_AREAS_STALE_TIME, + }); +} diff --git a/client/src/services/serviceAreas/index.ts b/client/src/services/serviceAreas/index.ts new file mode 100644 index 0000000..57b5971 --- /dev/null +++ b/client/src/services/serviceAreas/index.ts @@ -0,0 +1,3 @@ +export { useServiceAreas } from './hooks/useServiceAreas'; +export { useAddServiceArea } from './hooks/useAddServiceArea'; +export { useRemoveServiceArea } from './hooks/useRemoveServiceArea'; diff --git a/client/src/services/serviceAreas/keys.ts b/client/src/services/serviceAreas/keys.ts new file mode 100644 index 0000000..800569b --- /dev/null +++ b/client/src/services/serviceAreas/keys.ts @@ -0,0 +1,11 @@ +import type { PageParams } from '@/lib/api/types'; + +/** + * React Query key factory for the nurse service-areas domain. Add/remove invalidate + * `serviceAreaKeys.lists()` so the chip list always reflects reality. + */ +export const serviceAreaKeys = { + all: ['service-areas'] as const, + lists: () => [...serviceAreaKeys.all, 'list'] as const, + list: (params?: PageParams) => [...serviceAreaKeys.lists(), params ?? {}] as const, +}; diff --git a/client/src/services/serviceAreas/types.ts b/client/src/services/serviceAreas/types.ts new file mode 100644 index 0000000..bfd25f0 --- /dev/null +++ b/client/src/services/serviceAreas/types.ts @@ -0,0 +1,52 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Nurse service-areas domain — the cities/districts a nurse will travel to, nurse-scoped and + * tenancy-enforced server-side. Shapes mirror the b4 contract + * (`dev/contracts/domains/geography-addresses.md` → `NurseServiceAreaDto`); the wire is camelCase. + * + * **`districtId = null` = the whole city** — a real coverage choice ("I cover the entire city"), + * never missing data. Search (f6) treats a whole-city row as matching every district in that + * city. `UNIQUE(nurseId, cityId, districtId)` is enforced server-side (a duplicate returns 409), + * treating `null` district as a real value. + */ + +/** The b4 wire shape returned by every `nurse_service_areas/*` endpoint. */ +export interface NurseServiceArea { + id: number; + cityId: number; + cityNameFa: string; + cityNameEn: string; + districtId: number | null; + districtNameFa: string | null; + districtNameEn: string | null; + isWholeCity: boolean; + isActive: boolean; +} + +/** Add input — omit/`null` `districtId` for a whole-city area. */ +export interface AddServiceAreaInput { + cityId: number; + districtId?: number | null; +} + +/** The domain's API seam — a mock and the real client both implement this interface. */ +export interface ServiceAreasApi { + list(params?: PageParams): Promise>; + add(input: AddServiceAreaInput): Promise; + remove(id: number): Promise; +} + +/** + * The client-side duplicate guard (the fast path before firing the add): a `(cityId, districtId)` + * pair is a duplicate if it already exists in the nurse's areas, **treating a `null` district as a + * real value** (whole-city vs a specific district are distinct, but two whole-city rows collide). + * The server's `UNIQUE` index + 409 is the source of truth; this just avoids a doomed round-trip. + */ +export function areaExists( + areas: NurseServiceArea[], + cityId: number, + districtId: number | null, +): boolean { + return areas.some((area) => area.cityId === cityId && (area.districtId ?? null) === (districtId ?? null)); +} diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 9aeb9b8..d152ad5 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-3-b4 — Addresses, map picker & nurse coverage areas — 2026-07-03 +- **Shipped:** three domain services — `services/geography` (cached province→city→district reference lookups; + **Infinity `staleTime`** + shared `geographyKeys`; `useProvinces`/`useCities`/`useDistricts`; seam+mock+client), + `services/addresses` (address book CRUD + set-primary; single-primary invariant; every mutation invalidates + the list; `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/`useSetPrimaryAddress`), + `services/serviceAreas` (coverage add/remove; `areaExists` dup-guard; `useServiceAreas`/`useAddServiceArea`/ + `useRemoveServiceArea`). Shared composites (`src/components/geography/`, each tested): `CascadingRegionSelect` + (drives the cached cascade), `AddressMapPicker` (map-pin **stand-in** emitting real lat/lng), `AddressForm`, + `AddressCard`. Screens: **customer address book** (`/addresses`, reached from a profile-hub link — cascade + + map pin dialog, set-primary, delete, empty/skeleton), **nurse coverage editor** (`/nurse/coverage`, new + sidebar tab — chips, whole-city/specific-district scope toggle, inline duplicate block + 409, "won't appear + in search" empty warning). Added `geo`/`address`/`coverage` i18n namespaces + `nav.coverage` (both locales); + `location`/`delete`/`coverage` icons; routes `ADDRESSES`/`NURSE_COVERAGE`. +- **Consumes:** dev/contracts/domains/geography-addresses.md (backend-phase-4). Routes `api/v1/geo/{provinces, + cities,districts}`, `api/v1/customer_addresses/{list,create,update,set_primary,delete}`, `api/v1/ + nurse_service_areas/{list,add,remove}`. Wire camelCase; geo query params snake_case; 409 on duplicate coverage. +- **Mocked client-side:** `services/geography` (`USE_GEOGRAPHY_MOCK`), `services/addresses` (`USE_ADDRESSES_MOCK`), + `services/serviceAreas` (`USE_SERVICE_AREAS_MOCK`) — all default `true`; real clients wired for a one-line flip. + The `AddressMapPicker` is a stand-in (no real map tiles). See mocks-registry + the report. +- **Reviewed:** 5-dimension adversarial review → 3 findings fixed (map marker RTL transform; `page_size`→`pageSize` + pagination casing on the real list calls; coverage "districts" dead-end on a district-less city). +- **Gate:** npm run check green · npm run test:ci green (129 tests, +17 across 5 suites) · npm run build green + with NEXT_PUBLIC_API_URL set (routes /addresses, /nurse/coverage generated). +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-008 accept the map pin on address create/update, + REQ-009 `provinceId` on `CustomerAddressDto` for edit prefill). + ## frontend-phase-2-b3 — Onboarding & profiles (customer, patient, nurse, bank) — 2026-07-02 - **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented `relation`/`conditions`; full CRUD seam + mock + real client; `usePatients`/`useCreatePatient`/ diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 80073af..fe921c5 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -94,3 +94,29 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`), flagging in case that changes. - **Status:** open + +## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02 +- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional + `latitude`/`longitude` (decimals) from the request body — the coordinates the user dropped with the map-pin + picker — and persist those when provided, only falling back to the `IGeocoder` when the client sends none. +- **Why:** The b4 contract's create body geocodes server-side from `addressLine`+city and does **not** accept + client coordinates, but f3 requires the user to **drop a pin** on the map (a hard client-side validation) so + the stored coordinate is the user's exact door location for the later EVV distance check (b9) — a geocoded + street centroid is coarser. The client already sends `latitude`/`longitude` in the create/update body and + echoes them locally; until the server accepts them, the real path silently ignores them and geocodes instead. +- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store + them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already + returns `latitude`/`longitude`. +- **Status:** open + +## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02 +- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`). +- **Why:** The address book's **edit** form prefills the cascading province → city → district dropdowns from a + saved address, and the city list is fetched **per province** (`geo/cities?province_id=`). The DTO carries + `cityId` but not its province, so the client can't drive the city query to preselect the city without the + province id. The client currently augments `provinceId` behind the `services/addresses` seam (the mock + persists it; the real client echoes the just-saved choice), so editing an address that was **loaded fresh from + the server** can't prefill the province until this lands. `cityId` still implies the province server-side — + this is purely to prefill the client cascade. +- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`). +- **Status:** open diff --git a/dev/shared-working-context/reports/frontend-phase-3-report.md b/dev/shared-working-context/reports/frontend-phase-3-report.md new file mode 100644 index 0000000..f5a5409 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-3-report.md @@ -0,0 +1,108 @@ +# Frontend Phase 3 — Addresses, map picker & nurse coverage areas (b4) + +**Track:** frontend · **Consumes:** [`dev/contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md) (backend-phase-4) +· **Unlocks:** f7 booking request (needs a chosen address) + f6 search (needs nurse coverage areas) +· **Date:** 2026-07-03 · **Gate:** `npm run check` green · `npm run test:ci` green (129 tests, +17 across 5 new suites) · `npm run build` green (routes `/[locale]/addresses`, `/[locale]/nurse/coverage` generated) + +## What shipped + +Both actors get their *place* on the map — pure geography, no money, no clinical data. + +### Three domain services (mirroring the `patients`/`nurse` template) +- **`services/geography`** — the cached province → city → district reference lookups. `types.ts` + (`Province`/`City`/`District` + the `GeographyApi` seam), `keys.ts` (`geographyKeys.{provinces,cities,districts}`), + `constants.ts` (`USE_GEOGRAPHY_MOCK`, **`GEO_STALE_TIME = Infinity`** + `GEO_GC_TIME`, `CITY_CENTROIDS` + + `cityCentroid()`), `names.ts` (`pickRegionName`), `apis/{clientApi,mockApi,seed,index}.ts`, hooks + `useProvinces`/`useCities`(enabled on province)/`useDistricts`(enabled on city). **Aggressively cached**: each + level is fetched once per session and shared across both editors (and later f6 search). +- **`services/addresses`** — the customer address book. `types.ts` (`CustomerAddress` = wire `CustomerAddressDto` + + client-augmented `provinceId`; `CreateAddressInput`; `AddressesApi`), `keys.ts`, `constants.ts`, full + seam+mock+client, hooks `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/ + `useSetPrimaryAddress` — **every mutation invalidates `addressKeys.lists()`** (set-primary flips two rows, so + invalidate, don't hand-patch). +- **`services/serviceAreas`** — the nurse coverage areas. `types.ts` (`NurseServiceArea`; `AddServiceAreaInput`; + `ServiceAreasApi`; the pure **`areaExists`** dup-guard), `keys.ts`, `constants.ts`, seam+mock+client, hooks + `useServiceAreas`/`useAddServiceArea`/`useRemoveServiceArea` (add/remove invalidate the list). + +### Two shared composites + two form/card composites (`src/components/geography/`, each tested) +- **`CascadingRegionSelect`** — province → city → district dependent MUI selects, **driving the cached geography + queries itself** so both editors drop it in with only `value`/`onChange`. Each level enables on its parent, + resets its children on change; a whole-city-only city surfaces the **whole-city** affordance; district is + optional. Guards edit-prefill against out-of-range values before options load. +- **`AddressMapPicker`** — the **map-pin stand-in** (see Mocks). A tappable/draggable marker canvas that emits + real `{ latitude, longitude }`; marker positioned with inline `style` (physical left/top) + `dir="ltr"` so the + RTL stylis plugin can't mirror the pin off its click point. Centres on the chosen city's centroid. +- **`AddressForm`** — the add/edit body: cascade + map pin + title + street + set-primary toggle; validates + **city required, pin required**, title + street required, district optional. Emits `CreateAddressInput`. +- **`AddressCard`** — presentational list card: title + primary badge (reuses the f0 `StatusChip`), region label, + street line, edit/delete/set-primary; set-primary shows only on non-primary cards (never two primaries). + +### Screens +- **Customer address book** (`/addresses`, reached from a profile-hub link) — cards with primary badge, add/edit + dialog (cascade + map pin), set-primary, soft-delete confirm, empty + skeleton states. +- **Nurse coverage editor** (`/nurse/coverage`, new sidebar tab) — area chips (whole-city shown explicitly), an + add control (cascade + whole-city/specific-districts scope toggle), **inline duplicate block** (client + `areaExists` fast path + the server 409 mapped to the same message), remove confirm, and the empty-state + **"won't appear in search"** warning. + +### Wiring +- `constants/routes.ts`: `ADDRESSES`, `NURSE_COVERAGE`. `AppIcon` registry: `location`, `delete`, `coverage`. +- i18n: new `geo`/`address`/`coverage` namespaces + `nav.coverage` in **both** `en.json` and `fa.json` (identical + key sets, RTL-first). Colours from `tokens.css` only. +- `client/CLAUDE.md` Project Structure + i18n namespaces + the reference-data caching convention updated. + +## What is now testable, and exactly how + +Run `cd client && npm run dev`, sign in (f1-b2 OTP). All three services default to their client mock, so the +flows work **without the backend running**. +1. **Cascading dropdowns + caching.** Customer → Profile → *Manage addresses* → *Add address*. Province → city → + district cascade; a whole-city-only city (Mashhad/Isfahan/…) shows the whole-city affordance. Re-open *Add + address*: the lists come **from cache** (React Query Devtools shows no refetch). +2. **Add with a map pin + set primary.** Pick city (+ optional district), **drop a pin**, enter title + street, + toggle primary, save → the card shows a primary badge. Add a second, set *it* primary → exactly one badge + moves. Save without a city or pin → inline errors. +3. **Nurse coverage + duplicate block.** Nurse → *Coverage*. Empty → the "won't appear in search" warning. Add a + **whole-city** area (a chip); add a **city + district** area; add the **same** pair again → inline "already + covered", no request fired; remove → the chip disappears. +4. **i18n / RTL.** Flip `fa`↔`en`: every label/empty/error/duplicate string translates; the cascade, chips, and + map controls mirror correctly; colours match the brand tokens. +5. `npm run check`, `npm run test:ci`, `npm run build` all pass. + +## What is mocked client-side (and how f-next swaps it) + +All three services are behind a `services/{domain}` seam with a `USE_*_MOCK` flag (**default `true`**) and a +real `clientApi` already wired to the contract routes — the swap is a one-line flag flip per service. The +**`IGeocoder`** seam is backend-owned (backend-phase-4); the client only sends the picked coordinates. The +`AddressMapPicker` is a **stand-in** (no Neshan/Google tiles) behind a component boundary — a real map drops in +without touching `AddressForm`. See the **mock registry** for the exact rows + make-it-real steps. + +## Contract consumed + gaps filed + +Consumed `dev/contracts/domains/geography-addresses.md` (camelCase wire, snake_case query params, action-style +routes, 409 on duplicate coverage, single-primary address). Two gaps filed in +[`for-backend.md`](../frontend/requests/for-backend.md): +- **REQ-008** — accept the client-picked `latitude`/`longitude` on address create/update (the contract geocodes + server-side today; f3 requires the user's dropped pin for EVV precision). The client sends them + echoes locally. +- **REQ-009** — add `provinceId` to `CustomerAddressDto` so the edit form can prefill the province→city cascade + (the city list is fetched per-province). Client-augmented behind the seam meanwhile. + +## Adversarial review (pre-merge) + +Ran a 5-dimension multi-agent review (contract fidelity · conventions/i18n · single-primary · coverage +dedup/cascade · map/RTL/caching) with an adversarial verify pass. **3 findings confirmed and fixed:** +1. **(high, RTL)** `AddressMapPicker` marker's centering `transform` was in `sx`, so stylis-plugin-rtl + flipped its X on the default `fa` locale — the pin rendered ~one icon-width off the tap point. Moved the + transform into the inline `style` alongside the left/top offsets. +2. **(med, contract)** `addresses`/`serviceAreas` `list` sent `page_size` (snake_case), which the server's + `PageSize` binder ignores (only the geo lookups are explicitly snake_cased) → truncated lists once the real + endpoints are used. Changed both to `pageSize`, matching the patients template. +3. **(med, UX)** The coverage "specific districts" scope dead-ended on a whole-city-only city (district-less) + with contradictory `no_districts`↔`district_required` messages. The page now reads the cached districts to + force whole-city for such cities (disables the "districts" toggle), so the add never dead-ends. + +## Follow-ups for later phases +- **f7 booking request** consumes the chosen customer address (id + coordinates). +- **f6 search** consumes nurse coverage areas (whole-city rows match every district; reuse the `geography` cache + + `geographyKeys`, don't reinvent). The same-gender filter is f6, not here. +- When REQ-008/REQ-009 land, flip `USE_ADDRESSES_MOCK` to `false`. +- Swap the `AddressMapPicker` stand-in for a real map (mock registry row).