211 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|