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
@@ -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<ApiEnvelope<Paginated<CustomerAddressDto>>>(`${BASE}/list?${query.toString()}`),
);
return { ...page, items: page.items.map((dto) => toAddress(dto)) };
},
create: async (input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
update: async (id, input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
setPrimary: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
},
remove: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/delete/${id}`, { method: 'DELETE' });
},
};
@@ -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;
@@ -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<Paginated<CustomerAddress>> => {
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<CustomerAddress> => {
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<CustomerAddress> => {
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<void> => {
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<void> => {
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 }));
}
},
};
@@ -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;
@@ -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,
});
}
@@ -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() });
},
});
}
@@ -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() });
},
});
}
@@ -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() });
},
});
}
@@ -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() });
},
});
}
+5
View File
@@ -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';
+14
View File
@@ -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,
};
+79
View File
@@ -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<Paginated<CustomerAddress>>;
create(input: CreateAddressInput): Promise<CustomerAddress>;
update(id: number, input: UpdateAddressInput): Promise<CustomerAddress>;
setPrimary(id: number): Promise<void>;
remove(id: number): Promise<void>;
}
@@ -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<ApiEnvelope<Province[]>>(`${BASE}/provinces`)),
listCities: async (provinceId: number) =>
unwrap(await clientFetch<ApiEnvelope<City[]>>(`${BASE}/cities?province_id=${provinceId}`)),
listDistricts: async (cityId: number) =>
unwrap(await clientFetch<ApiEnvelope<District[]>>(`${BASE}/districts?city_id=${cityId}`)),
};
@@ -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;
@@ -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 = <T extends { sortOrder: number }>(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<Province[]> => {
await sleep(MOCK_LATENCY_MS);
return [...SEED_PROVINCES].sort(bySortOrder);
},
listCities: async (provinceId: number): Promise<City[]> => {
await sleep(MOCK_LATENCY_MS);
return SEED_CITIES.filter((city) => city.provinceId === provinceId).sort(bySortOrder);
},
listDistricts: async (cityId: number): Promise<District[]> => {
await sleep(MOCK_LATENCY_MS);
return SEED_DISTRICTS.filter((district) => district.cityId === cityId).sort(bySortOrder);
},
};
@@ -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);
}
@@ -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<number, { latitude: number; longitude: number }> = {
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;
}
@@ -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,
});
}
@@ -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,
});
}
@@ -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,
});
}
+3
View File
@@ -0,0 +1,3 @@
export { useProvinces } from './hooks/useProvinces';
export { useCities } from './hooks/useCities';
export { useDistricts } from './hooks/useDistricts';
+12
View File
@@ -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,
};
+10
View File
@@ -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;
}
+44
View File
@@ -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<Province[]>;
listCities(provinceId: number): Promise<City[]>;
listDistricts(cityId: number): Promise<District[]>;
}
@@ -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<ApiEnvelope<Paginated<NurseServiceArea>>>(`${BASE}/list?${query.toString()}`),
);
},
add: async (input: AddServiceAreaInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceArea>>(`${BASE}/add`, {
method: 'POST',
body: JSON.stringify({ cityId: input.cityId, districtId: input.districtId ?? null }),
}),
),
remove: async (id: number) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/remove/${id}`, { method: 'DELETE' });
},
};
@@ -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;
@@ -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<Paginated<NurseServiceArea>> => {
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<NurseServiceArea> => {
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<void> => {
await sleep(MOCK_LATENCY_MS);
store = store.filter((area) => area.id !== id);
},
};
@@ -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);
});
});
@@ -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;
@@ -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() });
},
});
}
@@ -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() });
},
});
}
@@ -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,
});
}
@@ -0,0 +1,3 @@
export { useServiceAreas } from './hooks/useServiceAreas';
export { useAddServiceArea } from './hooks/useAddServiceArea';
export { useRemoveServiceArea } from './hooks/useRemoveServiceArea';
+11
View File
@@ -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,
};
+52
View File
@@ -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<Paginated<NurseServiceArea>>;
add(input: AddServiceAreaInput): Promise<NurseServiceArea>;
remove(id: number): Promise<void>;
}
/**
* 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));
}