frontend phase 3: geography — addresses, map-pin picker & nurse coverage areas

Three domain services (mirroring the patients/nurse template): services/geography
(cached province→city→district lookups; Infinity staleTime + shared geographyKeys),
services/addresses (address book CRUD + set-primary; single-primary invariant), and
services/serviceAreas (coverage add/remove; areaExists dup-guard, districtId=null = whole city).

Four tested composites in src/components/geography: CascadingRegionSelect (drives the
cascade queries), AddressMapPicker (map-pin stand-in emitting real lat/lng), AddressForm,
AddressCard. Screens: customer address book (/addresses, reached from the profile hub) and
nurse coverage editor (/nurse/coverage, new sidebar tab, inline duplicate block + 409).

Adds geo/address/coverage i18n namespaces (both locales), location/delete/coverage icons,
ADDRESSES/NURSE_COVERAGE routes. Consumes the b4 geography-addresses contract; filed REQ-008
(accept the map pin on create/update) and REQ-009 (provinceId on CustomerAddressDto) for gaps.

Gate: npm run check + npm run test:ci (129, +17) + npm run build all green. A 5-dimension
adversarial review fixed 3 findings (map-marker RTL transform, page_size→pageSize pagination
casing, coverage districts-scope dead-end on district-less cities).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:50:41 +03:30
parent 1c266523bc
commit b8934f531d
56 changed files with 2627 additions and 3 deletions
@@ -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')}
</AppButton>
<Divider />
{/* 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. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}>
<AppIcon icon="location" size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{ta('manage_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{ta('manage_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
startIcon="location"
to={`/${locale}${ROUTES.ADDRESSES}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{ta('manage_cta')}
</AppButton>
</Stack>
</Paper>
</Box>
);
};