Files
baya-monorepo/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx
T
2026-07-27 22:27:04 +03:30

211 lines
7.6 KiB
TypeScript

'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, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
import { ApiError } from '@/lib/api/errors';
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
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).
*
* 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');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useServiceAreas();
const addArea = useAddServiceArea();
const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [cityError, setCityError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
const areas = data?.items ?? [];
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 resetForm = () => {
setRegion(EMPTY_REGION);
setCityError(false);
setDuplicate(false);
};
const handleAdd = () => {
const cityInvalid = region.cityId == null;
setCityError(cityInvalid);
setDuplicate(false);
if (cityInvalid) return;
const cityId = region.cityId as number;
// 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)) {
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' }),
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
});
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" width={160} height={32} />
))}
</Stack>
) : areas.length > 0 ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('areas_heading')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{areas.map((area) => (
<Chip
key={area.id}
label={chipLabel(area)}
onDelete={() => setRemoveTarget(area)}
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }}
/>
))}
</Box>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_warning')}
</Typography>
</Box>
</Stack>
</Paper>
)}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')}
</Typography>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
setDuplicate(false);
}}
cityError={cityError}
cityErrorText={t('city_required')}
/>
{duplicate ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('duplicate')}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
startIcon="add"
onClick={handleAdd}
disabled={addArea.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{addArea.isPending ? t('adding') : t('add')}
</AppButton>
</Stack>
</Paper>
<Dialog open={Boolean(removeTarget)} onClose={() => setRemoveTarget(null)}>
<DialogTitle>{t('remove_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('remove_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRemoveTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmRemove}>
{t('remove_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}