ui phase 9
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell } from '@/components';
|
||||
import { AddressCard, AddressForm } from '@/components/geography';
|
||||
import {
|
||||
useAddresses,
|
||||
@@ -16,9 +16,9 @@ import type { CreateAddressInput, CustomerAddress } from '@/services/addresses/t
|
||||
|
||||
/**
|
||||
* The customer address book — a cached, invalidate-on-mutation list of the customer's saved
|
||||
* addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm),
|
||||
* and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen
|
||||
* address later feeds the f7 booking request.
|
||||
* addresses with add/edit (the cascading dropdowns + map pin in a full-screen-on-mobile dialog),
|
||||
* soft-delete (confirm), and set-primary (exactly one badge). Loading skeleton, error (with
|
||||
* retry), and empty states are all handled. The chosen address later feeds the f7 booking request.
|
||||
*/
|
||||
export default function AddressesPage() {
|
||||
const t = useTranslations('address');
|
||||
@@ -26,13 +26,14 @@ export default function AddressesPage() {
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = useAddresses();
|
||||
const { data, isLoading, isError, refetch } = useAddresses();
|
||||
const createAddress = useCreateAddress();
|
||||
const updateAddress = useUpdateAddress();
|
||||
const deleteAddress = useDeleteAddress();
|
||||
const setPrimary = useSetPrimaryAddress();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [editing, setEditing] = useState<CustomerAddress | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(null);
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function AddressesPage() {
|
||||
};
|
||||
|
||||
const addresses = data?.items ?? [];
|
||||
const isEmpty = !isLoading && addresses.length === 0;
|
||||
const isEmpty = !isLoading && !isError && addresses.length === 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -91,7 +92,7 @@ export default function AddressesPage() {
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty ? (
|
||||
{!isEmpty && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -104,6 +105,8 @@ export default function AddressesPage() {
|
||||
<Skeleton key={key} variant="rounded" height={104} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
icon="location"
|
||||
@@ -125,6 +128,9 @@ export default function AddressesPage() {
|
||||
addressLine={address.addressLine}
|
||||
isPrimary={address.isPrimary}
|
||||
primaryLabel={t('primary')}
|
||||
hasPin={address.latitude != null && address.longitude != null}
|
||||
pinSetLabel={t('pin_set')}
|
||||
pinMissingLabel={t('pin_missing')}
|
||||
onEdit={() => openEdit(address)}
|
||||
onDelete={() => setDeleteTarget(address)}
|
||||
onSetPrimary={() =>
|
||||
@@ -142,50 +148,50 @@ export default function AddressesPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<AddressForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
title: editing.title,
|
||||
provinceId: editing.provinceId,
|
||||
cityId: editing.cityId,
|
||||
districtId: editing.districtId,
|
||||
addressLine: editing.addressLine,
|
||||
latitude: editing.latitude,
|
||||
longitude: editing.longitude,
|
||||
isPrimary: editing.isPrimary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
submitting={createAddress.isPending || updateAddress.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormDialogShell
|
||||
open={formOpen}
|
||||
title={editing ? t('edit_title') : t('add_title')}
|
||||
dirty={formDirty}
|
||||
onClose={closeForm}
|
||||
closeLabel={tc('close')}
|
||||
discardTitle={tc('discard_title')}
|
||||
discardBody={tc('discard_body')}
|
||||
discardConfirmLabel={tc('discard_confirm')}
|
||||
discardCancelLabel={tc('cancel')}
|
||||
>
|
||||
<AddressForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
title: editing.title,
|
||||
provinceId: editing.provinceId,
|
||||
cityId: editing.cityId,
|
||||
districtId: editing.districtId,
|
||||
addressLine: editing.addressLine,
|
||||
latitude: editing.latitude,
|
||||
longitude: editing.longitude,
|
||||
isPrimary: editing.isPrimary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
submitting={createAddress.isPending || updateAddress.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</FormDialogShell>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>{t('delete_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('delete_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setDeleteTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('delete_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t('delete_title')}
|
||||
body={t('delete_body')}
|
||||
confirmLabel={t('delete_confirm')}
|
||||
cancelLabel={tc('cancel')}
|
||||
confirmColor="error"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+651
-229
@@ -1,41 +1,55 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Drawer,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { AppButton, AppIcon, ConfirmDialog, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { ROUTES, bookingDetailPath } from '@/constants';
|
||||
import { formatShamsiDate, formatShamsiMonthYear } from '@/utils';
|
||||
import { bookingKeys } from '@/services/bookings/keys';
|
||||
import { usePatient } from '@/services/patients';
|
||||
import { birthDateToAge } from '@/services/patients/age';
|
||||
import { useRecordAccess, usePatientCareRecord, usePatientHistory, useUpdateCareRecord } from '@/services/patientRecords';
|
||||
import { CARE_RECORD_TABS } from '@/services/patientRecords/types';
|
||||
import { CARE_RECORD_TABS, DOSE_UNITS, FREQUENCY_PRESETS, TIME_OF_DAY_CODES } from '@/services/patientRecords/types';
|
||||
import type {
|
||||
CareRecordTab,
|
||||
CareTask,
|
||||
DoseUnit,
|
||||
FamilyCareRecord,
|
||||
FrequencyPreset,
|
||||
Medication,
|
||||
RoutineItem,
|
||||
TimeOfDayCode,
|
||||
VisitNote,
|
||||
} from '@/services/patientRecords/types';
|
||||
|
||||
/**
|
||||
* E2 — Patient care-record viewer (پروندهٔ مراقبت). The **family-owned, patient-scoped** record with four
|
||||
* tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks; the **سوابق**
|
||||
* (nurse visit notes) are read-only to everyone. A persistent ownership banner states the record belongs to
|
||||
* the family. The clinical-access check gates the whole screen (a `403` → a clear, non-leaking access-denied
|
||||
* card — never partial clinical data).
|
||||
* tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks via per-item
|
||||
* bottom sheets (never a whole-list edit mode — switching tabs mid-edit loses nothing by construction); the
|
||||
* **سوابق** (nurse visit notes) are read-only to everyone, grouped into a Shamsi month-by-month timeline. A
|
||||
* persistent ownership banner states the record belongs to the family. The clinical-access check gates the
|
||||
* whole screen (a `403` → a clear, non-leaking access-denied card — never partial clinical data).
|
||||
*/
|
||||
export default function PatientRecordPage() {
|
||||
const t = useTranslations('records');
|
||||
@@ -157,15 +171,184 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car
|
||||
};
|
||||
|
||||
if (tab === 'medications') {
|
||||
return <MedicationsTab data={data.medications} canEdit={canEdit} saving={update.isPending} onSave={(meds, done) => save({ medications: meds }, done)} />;
|
||||
return (
|
||||
<MedicationsTab
|
||||
data={data.medications}
|
||||
canEdit={canEdit}
|
||||
saving={update.isPending}
|
||||
onSave={(meds, done) => save({ medications: meds }, done)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab === 'routine') {
|
||||
return <RoutineTab data={data.routine} canEdit={canEdit} saving={update.isPending} onSave={(items, done) => save({ routine: items }, done)} />;
|
||||
return (
|
||||
<RoutineTab data={data.routine} canEdit={canEdit} saving={update.isPending} onSave={(items, done) => save({ routine: items }, done)} />
|
||||
);
|
||||
}
|
||||
return <TasksTab data={data.tasks} canEdit={canEdit} saving={update.isPending} onSave={(tasks, done) => save({ tasks }, done)} />;
|
||||
}
|
||||
|
||||
// ── The responsive per-item sheet: Drawer(bottom) on mobile, Dialog on desktop; dirty-gated close ─────────
|
||||
function RecordItemSheet({
|
||||
open,
|
||||
title,
|
||||
dirty,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
dirty: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('common');
|
||||
const theme = useTheme();
|
||||
const mobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
|
||||
const requestClose = () => {
|
||||
if (dirty) setDiscardOpen(true);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
const body = (
|
||||
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="inherit" onClick={requestClose} aria-label={t('close')} sx={{ minWidth: 0, p: 1 }}>
|
||||
<AppIcon icon="close" size={20} />
|
||||
</AppButton>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
slotProps={{ paper: { sx: { borderTopLeftRadius: 'var(--bal-radius-lg)', borderTopRightRadius: 'var(--bal-radius-lg)', maxHeight: '88vh' } } }}
|
||||
>
|
||||
{body}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="xs">
|
||||
{body}
|
||||
</Dialog>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={discardOpen}
|
||||
title={t('discard_title')}
|
||||
body={t('discard_body')}
|
||||
confirmLabel={t('discard_confirm')}
|
||||
cancelLabel={t('cancel')}
|
||||
confirmColor="error"
|
||||
onConfirm={() => {
|
||||
setDiscardOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setDiscardOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetActions({
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
saving,
|
||||
canSave,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
saving: boolean;
|
||||
canSave: boolean;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const tc = useTranslations('common');
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: onDelete ? 'space-between' : 'flex-end', alignItems: 'center' }}>
|
||||
{onDelete ? (
|
||||
<AppButton variant="text" color="error" onClick={onDelete} disabled={saving}>
|
||||
{t('remove')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton variant="text" onClick={onCancel} disabled={saving}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
|
||||
{saving ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
|
||||
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
{onOpen ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={onOpen}
|
||||
sx={{ width: '100%', p: 1.5, justifyContent: 'flex-start', textAlign: 'start', borderRadius: 0, '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{children}</Box>
|
||||
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
|
||||
</Stack>
|
||||
</AppButton>
|
||||
) : (
|
||||
<Box sx={{ p: 1.5 }}>{children}</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeOfDayChipRow({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: TimeOfDayCode[];
|
||||
onChange: (next: TimeOfDayCode[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
value={value}
|
||||
onChange={(_, next: TimeOfDayCode[]) => onChange(next)}
|
||||
disabled={disabled}
|
||||
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiToggleButtonGroup-grouped': { border: '1px solid', borderColor: 'divider !important', borderRadius: '999px !important', mx: 0 } }}
|
||||
>
|
||||
{TIME_OF_DAY_CODES.map((code) => (
|
||||
<ToggleButton key={code} value={code} size="small" sx={{ textTransform: 'none', px: 1.5, borderRadius: '999px' }}>
|
||||
{t(`time_${code}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
function medicationSummary(m: Medication, t: ReturnType<typeof useTranslations>): string {
|
||||
const dose = m.doseAmount ? `${m.doseAmount} ${m.doseUnit ? t(`dose_unit_${m.doseUnit}`) : ''}`.trim() : null;
|
||||
const frequency = m.frequencyCode ? t(`frequency_${m.frequencyCode}`) : m.frequencyText;
|
||||
return [dose, frequency].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function MedicationsTab({
|
||||
data,
|
||||
canEdit,
|
||||
@@ -178,67 +361,193 @@ function MedicationsTab({
|
||||
onSave: (medications: Medication[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Medication[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<Medication | 'new' | null>(null);
|
||||
const [sheetDirty, setSheetDirty] = useState(false);
|
||||
const close = () => setSheetItem(null);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((m) => ({ ...m })));
|
||||
setEditing(true);
|
||||
const handleSave = (item: Medication) => {
|
||||
const next = sheetItem === 'new' ? [...data, item] : data.map((m) => (m.id === item.id ? item : m));
|
||||
onSave(next, close);
|
||||
};
|
||||
const update = (id: string, patch: Partial<Medication>) => setDraft((d) => d.map((m) => (m.id === id ? { ...m, ...patch } : m)));
|
||||
const remove = (id: string) => setDraft((d) => d.filter((m) => m.id !== id));
|
||||
const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, name: '', dosage: null, frequency: '', timingNote: null }]);
|
||||
const canSave = draft.every((m) => m.name.trim().length > 0);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('medications_empty')}>
|
||||
{data.map((m) => (
|
||||
<Paper key={m.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="medication" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{m.name}
|
||||
{m.dosage ? ` — ${m.dosage}` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{(m.frequency || m.timingNote) && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{[m.frequency, m.timingNote].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
const handleDelete = (id: string) => onSave(data.filter((m) => m.id !== id), close);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{draft.map((m) => (
|
||||
<Paper key={m.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<TextField label={t('med_name')} value={m.name} onChange={(e) => update(m.id, { name: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(m.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TextField label={t('med_dosage')} value={m.dosage ?? ''} onChange={(e) => update(m.id, { dosage: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
<TextField label={t('med_frequency')} value={m.frequency} onChange={(e) => update(m.id, { frequency: e.target.value })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
</Stack>
|
||||
<TextField label={t('med_timing')} value={m.timingNote ?? ''} onChange={(e) => update(m.id, { timingNote: e.target.value || null })} size="small" fullWidth />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions
|
||||
onAdd={add}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={() => onSave(draft, () => setEditing(false))}
|
||||
saving={saving}
|
||||
canSave={canSave}
|
||||
/>
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('medications_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((m) => (
|
||||
<RowCard key={m.id} onOpen={canEdit ? () => setSheetItem(m) : undefined}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="medication" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{m.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{medicationSummary(m, t) ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{medicationSummary(m, t)}
|
||||
</Typography>
|
||||
) : null}
|
||||
{m.timeOfDay.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{m.timeOfDay.map((code) => (
|
||||
<Typography
|
||||
key={code}
|
||||
variant="caption"
|
||||
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
|
||||
>
|
||||
{t(`time_${code}`)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</RowCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_medication')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_medication') : t('edit_medication')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<MedicationSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as Medication).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicationSheetBody({
|
||||
initial,
|
||||
saving,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: Medication | null;
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (item: Medication) => void;
|
||||
onDelete?: () => void;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? '');
|
||||
const [doseUnit, setDoseUnit] = useState<DoseUnit | ''>(initial?.doseUnit ?? '');
|
||||
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null);
|
||||
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? '');
|
||||
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
|
||||
const [timingNote, setTimingNote] = useState(initial?.timingNote ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
const dirty =
|
||||
name !== (initial?.name ?? '') ||
|
||||
doseAmount !== (initial?.doseAmount ?? '') ||
|
||||
doseUnit !== (initial?.doseUnit ?? '') ||
|
||||
frequencyCode !== (initial?.frequencyCode ?? null) ||
|
||||
frequencyText !== (initial?.frequencyText ?? '') ||
|
||||
timingNote !== (initial?.timingNote ?? '') ||
|
||||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
|
||||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
|
||||
onDirtyChange(dirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
|
||||
}, [name, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay, timingNote]);
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
id: initial?.id ?? `new-${Date.now()}`,
|
||||
name: name.trim(),
|
||||
doseAmount: doseAmount.trim() || null,
|
||||
doseUnit: doseUnit || null,
|
||||
frequencyCode,
|
||||
frequencyText: frequencyCode ? null : frequencyText.trim() || null,
|
||||
timeOfDay,
|
||||
timingNote: timingNote.trim() || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus />
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1.5 }}>
|
||||
<TextField
|
||||
label={t('med_dose_amount')}
|
||||
value={doseAmount}
|
||||
onChange={(e) => setDoseAmount(e.target.value)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField select label={t('med_dose_unit')} value={doseUnit} onChange={(e) => setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}>
|
||||
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<MenuItem key={unit} value={unit}>
|
||||
{t(`dose_unit_${unit}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('med_frequency')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{FREQUENCY_PRESETS.map((preset) => (
|
||||
<AppButton
|
||||
key={preset}
|
||||
variant={frequencyCode === preset ? 'contained' : 'outlined'}
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFrequencyCode(frequencyCode === preset ? null : preset);
|
||||
if (frequencyCode !== preset) setFrequencyText('');
|
||||
}}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
{t(`frequency_${preset}`)}
|
||||
</AppButton>
|
||||
))}
|
||||
</Stack>
|
||||
{!frequencyCode ? (
|
||||
<TextField
|
||||
label={t('med_frequency_text')}
|
||||
value={frequencyText}
|
||||
onChange={(e) => setFrequencyText(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('time_of_day')}
|
||||
</Typography>
|
||||
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
|
||||
</Stack>
|
||||
|
||||
<TextField label={t('med_timing')} value={timingNote} onChange={(e) => setTimingNote(e.target.value)} fullWidth size="small" />
|
||||
|
||||
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -256,60 +565,128 @@ function RoutineTab({
|
||||
onSave: (routine: RoutineItem[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<RoutineItem[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<RoutineItem | 'new' | null>(null);
|
||||
const [sheetDirty, setSheetDirty] = useState(false);
|
||||
const close = () => setSheetItem(null);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((r) => ({ ...r })));
|
||||
setEditing(true);
|
||||
const handleSave = (item: RoutineItem) => {
|
||||
const next = sheetItem === 'new' ? [...data, item] : data.map((r) => (r.id === item.id ? item : r));
|
||||
onSave(next, close);
|
||||
};
|
||||
const update = (id: string, patch: Partial<RoutineItem>) => setDraft((d) => d.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
const remove = (id: string) => setDraft((d) => d.filter((r) => r.id !== id));
|
||||
const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', timeOfDay: null, note: null }]);
|
||||
const canSave = draft.every((r) => r.label.trim().length > 0);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('routine_empty')}>
|
||||
{data.map((r) => (
|
||||
<Paper key={r.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="routine" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{r.label}
|
||||
{r.timeOfDay ? ` — ${r.timeOfDay}` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{r.note ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{r.note}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
const handleDelete = (id: string) => onSave(data.filter((r) => r.id !== id), close);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{draft.map((r) => (
|
||||
<Paper key={r.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<TextField label={t('routine_label')} value={r.label} onChange={(e) => update(r.id, { label: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(r.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TextField label={t('routine_time')} value={r.timeOfDay ?? ''} onChange={(e) => update(r.id, { timeOfDay: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
<TextField label={t('routine_note')} value={r.note ?? ''} onChange={(e) => update(r.id, { note: e.target.value || null })} size="small" sx={{ flex: 2, minWidth: 120 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions onAdd={add} onCancel={() => setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('routine_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((r) => (
|
||||
<RowCard key={r.id} onOpen={canEdit ? () => setSheetItem(r) : undefined}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="routine" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{r.timeOfDay.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{r.timeOfDay.map((code) => (
|
||||
<Typography
|
||||
key={code}
|
||||
variant="caption"
|
||||
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
|
||||
>
|
||||
{t(`time_${code}`)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{r.note}
|
||||
</Typography>
|
||||
) : null}
|
||||
</RowCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_routine')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_routine') : t('edit_routine')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<RoutineSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as RoutineItem).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutineSheetBody({
|
||||
initial,
|
||||
saving,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: RoutineItem | null;
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (item: RoutineItem) => void;
|
||||
onDelete?: () => void;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
|
||||
const [note, setNote] = useState(initial?.note ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
const dirty =
|
||||
label !== (initial?.label ?? '') ||
|
||||
note !== (initial?.note ?? '') ||
|
||||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
|
||||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
|
||||
onDirtyChange(dirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
|
||||
}, [label, note, timeOfDay]);
|
||||
|
||||
const canSave = label.trim().length > 0;
|
||||
|
||||
const handleSave = () =>
|
||||
onSave({
|
||||
id: initial?.id ?? `new-${Date.now()}`,
|
||||
label: label.trim(),
|
||||
timeOfDay,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('time_of_day')}
|
||||
</Typography>
|
||||
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
|
||||
</Stack>
|
||||
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" />
|
||||
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -327,62 +704,132 @@ function TasksTab({
|
||||
onSave: (tasks: CareTask[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<CareTask[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<CareTask | 'new' | null>(null);
|
||||
const [sheetDirty, setSheetDirty] = useState(false);
|
||||
const close = () => setSheetItem(null);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((task) => ({ ...task })));
|
||||
setEditing(true);
|
||||
const handleSave = (item: CareTask) => {
|
||||
const next = sheetItem === 'new' ? [...data, item] : data.map((task) => (task.id === item.id ? item : task));
|
||||
onSave(next, close);
|
||||
};
|
||||
const update = (id: string, patch: Partial<CareTask>) => setDraft((d) => d.map((task) => (task.id === id ? { ...task, ...patch } : task)));
|
||||
const remove = (id: string) => setDraft((d) => d.filter((task) => task.id !== id));
|
||||
const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', done: false }]);
|
||||
const canSave = draft.every((task) => task.label.trim().length > 0);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('tasks_empty')}>
|
||||
{data.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon={task.done ? 'verified' : 'tasks'} size={20} color={task.done ? 'var(--bal-success)' : 'var(--bal-text-secondary)'} />
|
||||
<Typography variant="body2" sx={{ color: task.done ? 'text.secondary' : undefined }}>
|
||||
{task.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
const handleDelete = (id: string) => onSave(data.filter((task) => task.id !== id), close);
|
||||
const toggleDone = (task: CareTask) => onSave(data.map((t2) => (t2.id === task.id ? { ...t2, done: !t2.done } : t2)), () => {});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{draft.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={task.done} onChange={(e) => update(task.id, { done: e.target.checked })} />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
aria-label={t('task_done')}
|
||||
/>
|
||||
<TextField label={t('task_label')} value={task.label} onChange={(e) => update(task.id, { label: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(task.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions onAdd={add} onCancel={() => setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('tasks_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
aria-label={t('task_done')}
|
||||
/>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
disabled={!canEdit}
|
||||
onClick={() => setSheetItem(task)}
|
||||
sx={{ flexGrow: 1, justifyContent: 'flex-start', textAlign: 'start', minWidth: 0 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: task.done ? 'text.secondary' : undefined }}>
|
||||
{task.label}
|
||||
</Typography>
|
||||
</AppButton>
|
||||
{canEdit ? <AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" /> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_task')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_task') : t('edit_task')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<TaskSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as CareTask).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History (سوابق) — patient-scoped, read-only, paged ────────────────────────────────────────────────────
|
||||
function TaskSheetBody({
|
||||
initial,
|
||||
saving,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: CareTask | null;
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (item: CareTask) => void;
|
||||
onDelete?: () => void;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [done, setDone] = useState(initial?.done ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
|
||||
}, [label, done]);
|
||||
|
||||
const canSave = label.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
|
||||
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} />
|
||||
<SheetActions
|
||||
onCancel={onCancel}
|
||||
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })}
|
||||
onDelete={onDelete}
|
||||
saving={saving}
|
||||
canSave={canSave}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History (سوابق) — patient-scoped, read-only, month-grouped, paged ─────────────────────────────────────
|
||||
// Best-effort read of the frozen variant display name from a cached booking snapshot — mirrors
|
||||
// BookingDetailView's helper; never fetched (would be an N+1 against the booking detail per note).
|
||||
function variantName(snapshotJson: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
|
||||
return parsed?.displayName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryTab({ patientId }: { patientId: number }) {
|
||||
const t = useTranslations('records');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const history = usePatientHistory(patientId, page);
|
||||
|
||||
@@ -413,10 +860,49 @@ function HistoryTab({ patientId }: { patientId: number }) {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
// Group this page's notes by Shamsi month (notes already arrive newest-first from the server).
|
||||
const groups: Array<{ month: string; notes: VisitNote[] }> = [];
|
||||
for (const note of items) {
|
||||
const month = formatShamsiMonthYear(note.recordedAt, locale);
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (lastGroup && lastGroup.month === month) lastGroup.notes.push(note);
|
||||
else groups.push({ month, notes: [note] });
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((note) => (
|
||||
<VisitNoteCard key={note.id} note={note} dateLabel={formatShamsiDate(note.recordedAt, locale)} authorFallback={t('author_fallback')} />
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{groups.map((group) => (
|
||||
<Stack key={group.month} direction="row" sx={{ gap: 1.5 }}>
|
||||
<Stack sx={{ alignItems: 'center', pt: 0.5 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} />
|
||||
<Box sx={{ width: '1px', flexGrow: 1, bgcolor: 'divider', mt: 0.5 }} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 1.5, pb: 1, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||
{group.month}
|
||||
</Typography>
|
||||
{group.notes.map((note) => {
|
||||
const doneCount = note.taskResults.filter((task) => task.done).length;
|
||||
const booking = note.bookingId != null ? queryClient.getQueryData(bookingKeys.bookingDetail(note.bookingId)) : undefined;
|
||||
const service =
|
||||
booking && typeof booking === 'object' && 'variantSnapshotJson' in booking
|
||||
? variantName((booking as { variantSnapshotJson: string }).variantSnapshotJson)
|
||||
: undefined;
|
||||
return (
|
||||
<VisitNoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
dateLabel={formatShamsiDate(note.recordedAt, locale)}
|
||||
authorFallback={t('author_fallback')}
|
||||
taskSummaryLabel={note.taskResults.length > 0 ? t('task_summary', { done: doneCount, total: note.taskResults.length }) : undefined}
|
||||
serviceLabel={service ?? undefined}
|
||||
bookingLinkLabel={note.bookingId != null ? t('view_booking') : undefined}
|
||||
onOpenBooking={note.bookingId != null ? () => router.push(`/${locale}${bookingDetailPath(note.bookingId as number)}`) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
{totalPages > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
@@ -436,70 +922,6 @@ function HistoryTab({ patientId }: { patientId: number }) {
|
||||
}
|
||||
|
||||
// ── Small shared bits ─────────────────────────────────────────────────────────────────────────────────────
|
||||
function SectionShell({
|
||||
canEdit,
|
||||
onEdit,
|
||||
empty,
|
||||
emptyLabel,
|
||||
children,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
empty: boolean;
|
||||
emptyLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const tc = useTranslations('records');
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{canEdit ? (
|
||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={onEdit} sx={{ alignSelf: 'flex-end' }}>
|
||||
{tc('edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
{empty ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{emptyLabel}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>{children}</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EditActions({
|
||||
onAdd,
|
||||
onCancel,
|
||||
onSave,
|
||||
saving,
|
||||
canSave,
|
||||
}: {
|
||||
onAdd: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
canSave: boolean;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const tc = useTranslations('common');
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onAdd} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_item')}
|
||||
</AppButton>
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" color="primary" onClick={onCancel} disabled={saving}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
|
||||
{saving ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToPatients() {
|
||||
const t = useTranslations('records');
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,26 +3,17 @@ import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell, PatientCard, PatientForm } from '@/components';
|
||||
import { patientRecordPath } from '@/constants';
|
||||
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
||||
import { birthDateToAge } from '@/services/patients/age';
|
||||
import type { CreatePatientInput, Patient } from '@/services/patients/types';
|
||||
|
||||
/**
|
||||
* E1 — the Patients tab: a cached, invalidate-on-mutation list of the customer's patients
|
||||
* with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton
|
||||
* and an empty state with the add CTA are both handled.
|
||||
* E1 — the care-circle tab («حلقه مراقبت»): a cached, invalidate-on-mutation list of the people the
|
||||
* customer arranges care for, with add/edit (the A4 form in a full-screen-on-mobile dialog) and soft
|
||||
* archive (confirm). Loading skeleton and an empty state with the add CTA are both handled.
|
||||
*/
|
||||
export default function PatientsPage() {
|
||||
const t = useTranslations('patients');
|
||||
@@ -38,6 +29,7 @@ export default function PatientsPage() {
|
||||
const archivePatient = useArchivePatient();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [editing, setEditing] = useState<Patient | null>(null);
|
||||
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
|
||||
|
||||
@@ -143,50 +135,52 @@ export default function PatientsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<PatientForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
displayName: editing.displayName,
|
||||
birthDate: editing.birthDate,
|
||||
gender: editing.gender,
|
||||
conditions: editing.conditions,
|
||||
relation: editing.relation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showRelation
|
||||
submitLabel={tc('save')}
|
||||
submitting={createPatient.isPending || updatePatient.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
cancelLabel={tc('cancel')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormDialogShell
|
||||
open={formOpen}
|
||||
title={editing ? t('edit_title') : t('add_title')}
|
||||
dirty={formDirty}
|
||||
onClose={closeForm}
|
||||
closeLabel={tc('close')}
|
||||
discardTitle={tc('discard_title')}
|
||||
discardBody={tc('discard_body')}
|
||||
discardConfirmLabel={tc('discard_confirm')}
|
||||
discardCancelLabel={tc('cancel')}
|
||||
>
|
||||
<PatientForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
displayName: editing.displayName,
|
||||
firstName: editing.firstName,
|
||||
lastName: editing.lastName,
|
||||
birthDate: editing.birthDate,
|
||||
gender: editing.gender,
|
||||
conditions: editing.conditions,
|
||||
relation: editing.relation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showRelation
|
||||
submitLabel={tc('save')}
|
||||
submitting={createPatient.isPending || updatePatient.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
cancelLabel={tc('cancel')}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</FormDialogShell>
|
||||
|
||||
<Dialog open={Boolean(archiveTarget)} onClose={() => setArchiveTarget(null)}>
|
||||
<DialogTitle>{t('archive_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('archive_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setArchiveTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmArchive}>
|
||||
{t('archive_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveTarget)}
|
||||
title={t('archive_title')}
|
||||
body={t('archive_body')}
|
||||
confirmLabel={t('archive_confirm')}
|
||||
cancelLabel={tc('cancel')}
|
||||
confirmColor="error"
|
||||
onClose={() => setArchiveTarget(null)}
|
||||
onConfirm={confirmArchive}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
|
||||
import { Box, Divider, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
ConfirmDialog,
|
||||
ErrorState,
|
||||
FormDialogShell,
|
||||
PhoneNumberField,
|
||||
ProfileSummary,
|
||||
} from '@/components';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { ROUTES } from '@/constants';
|
||||
@@ -13,51 +22,70 @@ import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profile
|
||||
import { useMe, useLogout } from '@/services/auth';
|
||||
import type { CustomerProfile } from '@/services/profiles/types';
|
||||
|
||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||
/** Customer profile — the account hub: identity header, grouped rows, emergency-contact status card. */
|
||||
export default function CustomerProfilePage() {
|
||||
const t = useTranslations('profile');
|
||||
const tc = useTranslations('common');
|
||||
const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
|
||||
const { data: me } = useMe();
|
||||
if (isLoading) return <AppLoading />;
|
||||
const { data: me, isLoading: meLoading } = useMe();
|
||||
|
||||
if (isLoading || meLoading) return <ProfileSkeleton />;
|
||||
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields
|
||||
// whose save could overwrite server truth.
|
||||
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
|
||||
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
||||
|
||||
return (
|
||||
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} />
|
||||
<AccountHub
|
||||
initial={profile ?? null}
|
||||
nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }}
|
||||
phone={me?.phone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomerProfileForm: FunctionComponent<{
|
||||
const AccountHub: FunctionComponent<{
|
||||
initial: CustomerProfile | null;
|
||||
nameFallback: { firstName: string | null; lastName: string | null };
|
||||
}> = ({ initial, nameFallback }) => {
|
||||
phone?: string;
|
||||
}> = ({ initial, nameFallback, phone }) => {
|
||||
const t = useTranslations('profile');
|
||||
const ta = useTranslations('address');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertCustomerProfile();
|
||||
const logout = useLogout();
|
||||
|
||||
const initialFirstName = initial?.firstName ?? nameFallback.firstName ?? '';
|
||||
const initialLastName = initial?.lastName ?? nameFallback.lastName ?? '';
|
||||
const initialLanguage = initial?.preferredLanguage ?? 'fa';
|
||||
const initialEmergencyName = initial?.defaultEmergencyContactName ?? '';
|
||||
const initialEmergencyPhone = digitsOnly(initial?.defaultEmergencyContactPhone ?? '');
|
||||
|
||||
const [firstName, setFirstName] = useState(initialFirstName);
|
||||
const [lastName, setLastName] = useState(initialLastName);
|
||||
const [language, setLanguage] = useState(initialLanguage);
|
||||
const [emergencyName, setEmergencyName] = useState(initialEmergencyName);
|
||||
const [emergencyPhone, setEmergencyPhone] = useState(initialEmergencyPhone);
|
||||
|
||||
const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
|
||||
const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
|
||||
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
|
||||
const [signOutOpen, setSignOutOpen] = useState(false);
|
||||
|
||||
const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? '');
|
||||
const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? '');
|
||||
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
|
||||
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
|
||||
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const [phoneError, setPhoneError] = useState(false);
|
||||
|
||||
const isComplete = Boolean(initial?.defaultEmergencyContactName && initial?.defaultEmergencyContactPhone);
|
||||
|
||||
const handleSave = () => {
|
||||
const nameInvalid = emergencyName.trim().length === 0;
|
||||
const phoneInvalid = !isIranianMobile(emergencyPhone);
|
||||
setNameError(nameInvalid);
|
||||
setPhoneError(phoneInvalid);
|
||||
if (nameInvalid || phoneInvalid) return;
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() || phone || '';
|
||||
const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone);
|
||||
|
||||
// Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch`
|
||||
// carries just the fields that sheet owns, the rest come from the shared draft state so editing
|
||||
// one section never blanks another (the bug the old flat form was one refactor away from).
|
||||
const save = (
|
||||
patch: Partial<Record<'firstName' | 'lastName' | 'preferredLanguage', string | null>>,
|
||||
onDone: () => void,
|
||||
) => {
|
||||
upsert.mutate(
|
||||
{
|
||||
defaultEmergencyContactName: emergencyName.trim(),
|
||||
@@ -65,135 +93,291 @@ const CustomerProfileForm: FunctionComponent<{
|
||||
firstName: firstName.trim() || null,
|
||||
lastName: lastName.trim() || null,
|
||||
preferredLanguage: language,
|
||||
...patch,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
onDone();
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
const savePersonal = () =>
|
||||
save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false));
|
||||
const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false));
|
||||
const saveEmergency = () => {
|
||||
const nameInvalid = emergencyName.trim().length === 0;
|
||||
const phoneInvalid = !isIranianMobile(emergencyPhone);
|
||||
setNameError(nameInvalid);
|
||||
setPhoneError(phoneInvalid);
|
||||
if (nameInvalid || phoneInvalid) return;
|
||||
save({}, () => setEmergencySheetOpen(false));
|
||||
};
|
||||
|
||||
const personalDirty = firstName !== initialFirstName || lastName !== initialLastName;
|
||||
const languageDirty = language !== initialLanguage;
|
||||
const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone;
|
||||
|
||||
const goTo = (path: string) => router.push(`/${locale}${path}`);
|
||||
|
||||
// Shared discard-confirm strings for every FormDialogShell instance below.
|
||||
const closeLabel = tc('close');
|
||||
const discardTitle = tc('discard_title');
|
||||
const discardBody = tc('discard_body');
|
||||
const discardConfirmLabel = tc('discard_confirm');
|
||||
const cancelLabel = tc('cancel');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
|
||||
{isComplete ? t('completion_done') : t('completion_todo')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
|
||||
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
|
||||
<EmergencyContactCard
|
||||
complete={emergencyComplete}
|
||||
name={emergencyName}
|
||||
phone={emergencyPhone}
|
||||
onEdit={() => setEmergencySheetOpen(true)}
|
||||
/>
|
||||
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
|
||||
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
|
||||
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
|
||||
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t('language')}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
sx={{ maxWidth: 220 }}
|
||||
{/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
|
||||
<ActorSwitcher target="nurse" />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
<AccountRow icon="logout" label={t('sign_out')} onClick={() => setSignOutOpen(true)} tone="error" />
|
||||
</Stack>
|
||||
|
||||
{/* اطلاعات شخصی */}
|
||||
<FormDialogShell
|
||||
open={personalSheetOpen}
|
||||
title={t('row_personal')}
|
||||
dirty={personalDirty}
|
||||
onClose={() => setPersonalSheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
||||
<MenuItem value="en">{t('language_en')}</MenuItem>
|
||||
</TextField>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
|
||||
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
|
||||
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
</FormDialogShell>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('emergency_section')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('emergency_name')}
|
||||
value={emergencyName}
|
||||
onChange={(e) => {
|
||||
setEmergencyName(e.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
fullWidth
|
||||
/>
|
||||
<PhoneNumberField
|
||||
label={t('emergency_phone')}
|
||||
value={emergencyPhone}
|
||||
onChange={(value) => {
|
||||
setEmergencyPhone(value);
|
||||
if (phoneError) setPhoneError(false);
|
||||
}}
|
||||
error={phoneError}
|
||||
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={upsert.isPending}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
{/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
|
||||
<FormDialogShell
|
||||
open={languageSheetOpen}
|
||||
title={t('row_language')}
|
||||
dirty={languageDirty}
|
||||
onClose={() => setLanguageSheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
{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')}
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('app_language')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{ta('manage_body')}
|
||||
{t('app_language_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
startIcon="location"
|
||||
to={`/${locale}${ROUTES.ADDRESSES}`}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
<LocaleSwitcher />
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextField
|
||||
select
|
||||
label={t('language')}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
helperText={t('language_hint')}
|
||||
>
|
||||
{ta('manage_cta')}
|
||||
</AppButton>
|
||||
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
||||
<MenuItem value="en">{t('language_en')}</MenuItem>
|
||||
</TextField>
|
||||
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</FormDialogShell>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Account affordances: sign-out has no home anywhere in the customer shell yet (a full hub
|
||||
redesign is deferred to phase 9) — one labeled row is enough for now. */}
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<ActorSwitcher target="nurse" />
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* مخاطب اضطراری */}
|
||||
<FormDialogShell
|
||||
open={emergencySheetOpen}
|
||||
title={t('emergency_section')}
|
||||
dirty={emergencyDirty}
|
||||
onClose={() => setEmergencySheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('app_language')}
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
<LocaleSwitcher />
|
||||
<TextField
|
||||
label={t('emergency_name')}
|
||||
value={emergencyName}
|
||||
onChange={(e) => {
|
||||
setEmergencyName(e.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
fullWidth
|
||||
/>
|
||||
<PhoneNumberField
|
||||
label={t('emergency_phone')}
|
||||
value={emergencyPhone}
|
||||
onChange={(v) => {
|
||||
setEmergencyPhone(v);
|
||||
if (phoneError) setPhoneError(false);
|
||||
}}
|
||||
error={phoneError}
|
||||
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
<SignOutRow />
|
||||
</FormDialogShell>
|
||||
|
||||
<ConfirmDialog
|
||||
open={signOutOpen}
|
||||
title={t('sign_out_confirm_title')}
|
||||
body={t('sign_out_confirm_body')}
|
||||
confirmLabel={t('sign_out')}
|
||||
cancelLabel={cancelLabel}
|
||||
confirmColor="error"
|
||||
onClose={() => setSignOutOpen(false)}
|
||||
onConfirm={() => {
|
||||
setSignOutOpen(false);
|
||||
logout.mutate();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountRow: FunctionComponent<{
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'error';
|
||||
}> = ({ icon, label, onClick, tone = 'default' }) => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
sx={{ background: 'none', border: 'none', p: 0, width: '100%', textAlign: 'start', font: 'inherit', cursor: 'pointer' }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={22} color={tone === 'error' ? 'var(--bal-error)' : 'var(--bal-primary)'} />
|
||||
<Typography variant="body1" sx={{ flexGrow: 1, fontWeight: 500 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{tone !== 'error' ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const EmergencyContactCard: FunctionComponent<{
|
||||
complete: boolean;
|
||||
name: string;
|
||||
phone: string;
|
||||
onEdit: () => void;
|
||||
}> = ({ complete, name, phone, onEdit }) => {
|
||||
const t = useTranslations('profile');
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{t('emergency_section')}
|
||||
</Typography>
|
||||
{complete ? (
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="a"
|
||||
href={`tel:${phone}`}
|
||||
dir="ltr"
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--bal-primary)', textDecoration: 'none' }}
|
||||
>
|
||||
{phone}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<AppButton variant="text" color="primary" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SignOutRow: FunctionComponent = () => {
|
||||
const t = useTranslations('profile');
|
||||
const { mutate: logout, isPending } = useLogout();
|
||||
const SheetActions: FunctionComponent<{
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
saveLabel: string;
|
||||
cancelLabel: string;
|
||||
}> = ({ onCancel, onSave, saving, saveLabel, cancelLabel }) => {
|
||||
const tc = useTranslations('common');
|
||||
return (
|
||||
<AppButton variant="text" color="error" startIcon="logout" onClick={() => logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('sign_out')}
|
||||
</AppButton>
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" onClick={onCancel} disabled={saving}>
|
||||
{cancelLabel}
|
||||
</AppButton>
|
||||
<AppButton color="primary" variant="contained" onClick={onSave} disabled={saving}>
|
||||
{saving ? tc('saving') : saveLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<Skeleton variant="circular" width={56} height={56} />
|
||||
<Skeleton variant="text" width={140} />
|
||||
<Skeleton variant="text" width={100} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={56} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user