ui phase 8

This commit is contained in:
hamid
2026-07-19 13:57:11 +03:30
parent edc38543fd
commit 1ef4feb911
41 changed files with 2113 additions and 667 deletions
@@ -2,36 +2,25 @@
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 { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, 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.
* can fan them out geographically. Areas render as chips (whole-city shown explicitly).
*
* ui-phase-8: **one control owns the whole-city choice** — `CascadingRegionSelect`'s own district
* level, whose "کل شهر" empty option *is* the choice (`districtId = null`, matching the serviceAreas
* contract both ways). The separate scope toggle this page used to render is gone — it let a nurse
* pick "specific districts" and then still land on the district select's own whole-city option,
* tripping a "district required" error the UI itself had offered. City is the only required field;
* leaving the district unset is a complete, valid whole-city submission, never an error state.
*/
export default function NurseCoveragePage() {
const t = useTranslations('coverage');
@@ -44,22 +33,12 @@ export default function NurseCoveragePage() {
const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [scope, setScope] = useState<Scope>('whole_city');
const [cityError, setCityError] = useState(false);
const [districtError, setDistrictError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(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')}`;
@@ -67,33 +46,21 @@ export default function NurseCoveragePage() {
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;
if (cityInvalid) return;
const cityId = region.cityId as number;
const districtId = effectiveScope === 'whole_city' ? null : region.districtId;
// Whatever the district select currently holds is the complete choice — `null` = whole city.
const districtId = region.districtId;
// Fast path: block a duplicate before firing (null district treated as a real value).
if (areaExists(areas, cityId, districtId)) {
@@ -123,6 +90,7 @@ export default function NurseCoveragePage() {
setRemoveTarget(null);
removeArea.mutate(id, {
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
});
};
@@ -191,38 +159,15 @@ export default function NurseCoveragePage() {
{t('add_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('scope_label')}
</Typography>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={effectiveScope}
onChange={(_event, next: Scope | null) => changeScope(next)}
>
<ToggleButton value="whole_city">{t('scope_whole_city')}</ToggleButton>
{/* A district-less city forces whole-city — disable the option rather than dead-end on it. */}
<ToggleButton value="districts" disabled={cityHasNoDistricts}>
{t('scope_districts')}
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<CascadingRegionSelect
value={region}
onChange={(next) => {
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 ? (