frontend phase 13

This commit is contained in:
hamid
2026-07-10 16:58:15 +03:30
parent 6186f54294
commit 85488bc25b
57 changed files with 3283 additions and 105 deletions
@@ -18,7 +18,7 @@ const PATIENT: Patient = {
conditions: ['elderly'],
};
function renderCard() {
function renderCard(extra: { onOpen?: () => void; openLabel?: string } = {}) {
const onEdit = jest.fn();
const onArchive = jest.fn();
render(
@@ -34,6 +34,7 @@ function renderCard() {
onArchive={onArchive}
editLabel="Edit"
archiveLabel="Archive"
{...extra}
/>
</ThemeProvider>,
);
@@ -57,4 +58,14 @@ describe('<PatientCard/> component', () => {
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onArchive).toHaveBeenCalledTimes(1);
});
it('opens the record from the identity area without firing edit/archive', async () => {
const user = userEvent.setup();
const onOpen = jest.fn();
const { onEdit, onArchive } = renderCard({ onOpen, openLabel: 'Open record' });
await user.click(screen.getByLabelText('Open record'));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(onEdit).not.toHaveBeenCalled();
expect(onArchive).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,10 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppIconButton } from '@/components/common';
import PatientHeader from '@/components/PatientHeader';
import type { Patient } from '@/services/patients/types';
export interface PatientCardProps {
@@ -23,11 +22,17 @@ export interface PatientCardProps {
onArchive: () => void;
editLabel: string;
archiveLabel: string;
/** When provided, tapping the identity area opens the patient's record (E2). The action buttons are unaffected. */
onOpen?: () => void;
/** Accessible label for the tappable identity area (required when `onOpen` is set). */
openLabel?: string;
}
/**
* Patient summary card for the E1 list — relation + name, age/gender, and condition chips,
* with edit and archive actions. All display text is translated by the caller.
* Patient summary card for the E1 list — the shared `PatientHeader` (relation + name, age/gender, condition
* chips) plus edit and archive actions. When `onOpen` is passed the identity area becomes a button that opens
* the E2 record viewer; the edit/archive icon buttons keep their own handlers. All display text is translated
* by the caller.
* @component PatientCard
*/
const PatientCard: FunctionComponent<PatientCardProps> = ({
@@ -41,40 +46,46 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
onArchive,
editLabel,
archiveLabel,
onOpen,
openLabel,
}) => {
const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
const header = (
<PatientHeader
displayName={patient.displayName}
relationLabel={relationLabel}
genderLabel={genderLabel}
ageLabel={ageLabel}
conditionLabels={conditionLabels}
noConditionsLabel={noConditionsLabel}
/>
);
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
<Stack sx={{ flexGrow: 1, gap: 0.75, minWidth: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{patient.displayName}
</Typography>
{relationLabel ? (
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }} />
) : null}
</Stack>
{meta ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{meta}
</Typography>
) : null}
{conditionLabels.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
{conditionLabels.map((label) => (
<Chip key={label} size="small" variant="outlined" label={label} />
))}
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{noConditionsLabel}
</Typography>
)}
</Stack>
{onOpen ? (
<Box
component="button"
type="button"
onClick={onOpen}
aria-label={openLabel}
sx={{
flexGrow: 1,
minWidth: 0,
textAlign: 'start',
background: 'none',
border: 'none',
p: 0,
cursor: 'pointer',
font: 'inherit',
color: 'inherit',
}}
>
{header}
</Box>
) : (
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{header}</Box>
)}
<Stack direction="row" sx={{ flexShrink: 0 }}>
<AppIconButton icon="edit" title={editLabel} aria-label={editLabel} size="small" onClick={onEdit} />
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import PatientHeader, { PatientHeaderProps } from './PatientHeader';
function renderHeader(props: Partial<PatientHeaderProps> = {}) {
return render(
<ThemeProvider>
<PatientHeader
displayName="Zahra Mohammadi"
relationLabel="Parent"
genderLabel="Female"
ageLabel="70 yrs"
conditionLabels={['Elderly']}
noConditionsLabel="No conditions"
{...props}
/>
</ThemeProvider>,
);
}
describe('<PatientHeader/> component', () => {
it('renders name, relation, meta line and conditions', () => {
renderHeader();
expect(screen.getByText('Zahra Mohammadi')).toBeInTheDocument();
expect(screen.getByText('Parent')).toBeInTheDocument();
expect(screen.getByText('70 yrs · Female')).toBeInTheDocument();
expect(screen.getByText('Elderly')).toBeInTheDocument();
});
it('shows the no-conditions caption when there are none', () => {
renderHeader({ conditionLabels: [] });
expect(screen.getByText('No conditions')).toBeInTheDocument();
});
it('omits the relation chip when no relation is set', () => {
renderHeader({ relationLabel: undefined });
expect(screen.queryByText('Parent')).not.toBeInTheDocument();
// meta line still renders age · gender
expect(screen.getByText('70 yrs · Female')).toBeInTheDocument();
});
});
@@ -0,0 +1,71 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
export interface PatientHeaderProps {
/** The patient's display name. */
displayName: string;
/** Translated relation label; omitted when the patient has no relation set. */
relationLabel?: string;
/** Translated gender label. */
genderLabel: string;
/** Translated age label (e.g. "70 yrs"); omitted when the birth date is unknown. */
ageLabel?: string;
/** Translated condition labels; empty renders the "no conditions" line. */
conditionLabels: string[];
noConditionsLabel: string;
}
/**
* The patient identity block — bold name + relation chip, a secondary "age · gender" meta line, and outlined
* condition chips (or a "no conditions" caption). Extracted from `PatientCard` so the E1 list card and the E2
* record viewer render the exact same header. Purely presentational; the caller translates every label and
* tolerates a missing relation / empty conditions (both are client-augmented, may be empty on a real read).
* @component PatientHeader
*/
const PatientHeader: FunctionComponent<PatientHeaderProps> = ({
displayName,
relationLabel,
genderLabel,
ageLabel,
conditionLabels,
noConditionsLabel,
}) => {
const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
return (
<Stack sx={{ gap: 0.75, minWidth: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{displayName}
</Typography>
{relationLabel ? (
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }} />
) : null}
</Stack>
{meta ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{meta}
</Typography>
) : null}
{conditionLabels.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
{conditionLabels.map((label) => (
<Chip key={label} size="small" variant="outlined" label={label} />
))}
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{noConditionsLabel}
</Typography>
)}
</Stack>
);
};
export default PatientHeader;
@@ -0,0 +1,4 @@
import PatientHeader from './PatientHeader';
export type { PatientHeaderProps } from './PatientHeader';
export { PatientHeader as default, PatientHeader };
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import RatingInput, { RatingInputProps } from './RatingInput';
function renderRating(props: Partial<RatingInputProps> = {}) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<RatingInput value={0} onChange={onChange} {...props} />
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<RatingInput/> component', () => {
it('renders max stars and exposes the current value', () => {
const { container } = renderRating({ value: 3 });
expect(container.querySelector('[data-rating="3"]')).toBeInTheDocument();
expect(container.querySelectorAll('[data-star]')).toHaveLength(5);
});
it('calls onChange with the clicked star value', async () => {
const user = userEvent.setup();
const { onChange } = renderRating({ value: 0 });
await user.click(screen.getByRole('radio', { name: '4' }));
expect(onChange).toHaveBeenCalledWith(4);
});
it('reflects the chosen value on the matching star', () => {
renderRating({ value: 2 });
expect(screen.getByRole('radio', { name: '2' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: '3' })).toHaveAttribute('aria-checked', 'false');
});
it('is a non-interactive display with no radios when readOnly', () => {
const { container, onChange } = renderRating({ value: 5, readOnly: true });
expect(container.querySelector('[data-rating="5"]')).toBeInTheDocument();
expect(container.querySelectorAll('[data-star]')).toHaveLength(0);
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import { AppIcon } from '@/components/common';
export interface RatingInputProps {
/** Current rating, 0max (0 = none chosen). */
value: number;
/** Called with the clicked star value. Omit (or set `readOnly`) for a display-only star row. */
onChange?: (value: number) => void;
/** Non-interactive display (e.g. a review card). */
readOnly?: boolean;
/** Number of stars (default 5). */
max?: number;
/** Icon size in px (default 28 interactive / caller-set for display). */
size?: number;
/** Accessible name for the whole control — translated by the caller. */
ariaLabel?: string;
}
const DEFAULT_MAX = 5;
const DEFAULT_SIZE = 28;
/**
* The 1max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is a
* `radio` in a `radiogroup`); otherwise a static star row (`role="img"`). Stars are token-coloured — filled
* = `var(--bal-warning)` (matching the existing rating star), empty = `var(--bal-divider)` — so it switches
* with the color scheme and never hard-codes a hex. Built on the registered `star` `AppIcon` (no MUI `Rating`
* dependency, so the control is fully deterministic to test).
* @component RatingInput
*/
const RatingInput: FunctionComponent<RatingInputProps> = ({
value,
onChange,
readOnly = false,
max = DEFAULT_MAX,
size = DEFAULT_SIZE,
ariaLabel,
}) => {
const interactive = !readOnly && Boolean(onChange);
const stars = Array.from({ length: max }, (_, i) => i + 1);
return (
<Box
role={interactive ? 'radiogroup' : 'img'}
aria-label={ariaLabel}
data-rating={value}
data-readonly={readOnly ? 'true' : undefined}
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}
>
{stars.map((n) => {
const color = n <= value ? 'var(--bal-warning)' : 'var(--bal-divider)';
if (!interactive) {
return <AppIcon key={n} icon="star" size={size} color={color} />;
}
return (
<IconButton
key={n}
role="radio"
aria-checked={n === value}
aria-label={String(n)}
data-star={n}
size="small"
onClick={() => onChange?.(n)}
sx={{ p: 0.25 }}
>
<AppIcon icon="star" size={size} color={color} />
</IconButton>
);
})}
</Box>
);
};
export default RatingInput;
@@ -0,0 +1,4 @@
import RatingInput from './RatingInput';
export type { RatingInputProps } from './RatingInput';
export { RatingInput as default, RatingInput };
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import ReviewTagSelector, { ReviewTagSelectorProps } from './ReviewTagSelector';
const CODES = ['punctual', 'kind', 'clean'] as const;
const LABELS: Record<string, string> = { punctual: 'Punctual', kind: 'Kind', clean: 'Clean' };
function renderSelector(props: Partial<ReviewTagSelectorProps> = {}) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<ReviewTagSelector
codes={CODES}
selected={[]}
onChange={onChange}
labelFor={(code) => LABELS[code] ?? code}
{...props}
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<ReviewTagSelector/> component', () => {
it('renders a chip per code with its label', () => {
renderSelector();
expect(screen.getByText('Punctual')).toBeInTheDocument();
expect(screen.getByText('Kind')).toBeInTheDocument();
expect(screen.getByText('Clean')).toBeInTheDocument();
});
it('adds a code to the selection when an unselected chip is clicked', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ selected: ['punctual'] });
await user.click(screen.getByText('Kind'));
expect(onChange).toHaveBeenCalledWith(['punctual', 'kind']);
});
it('removes a code when a selected chip is clicked', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ selected: ['punctual', 'kind'] });
await user.click(screen.getByText('Punctual'));
expect(onChange).toHaveBeenCalledWith(['kind']);
});
it('marks selected chips via aria-pressed', () => {
renderSelector({ selected: ['clean'] });
const cleanChip = screen.getByText('Clean').closest('[aria-pressed]');
expect(cleanChip).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -0,0 +1,58 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
export interface ReviewTagSelectorProps {
/** The stable tag codes to offer (the review vocabulary). */
codes: readonly string[];
/** Currently-selected codes. */
selected: string[];
/** Called with the next selection when a chip is toggled. */
onChange: (selected: string[]) => void;
/** Maps a code → its display label (the caller owns i18n; labels are keys off the code, never the wire). */
labelFor: (code: string) => string;
disabled?: boolean;
}
/**
* A multi-select chip group for the review tags (منظم/حرفه‌ای/…). Selected chips use the brand primary
* (MUI palette — no hard-coded hex); unselected are outlined. The component is i18n-free: the caller supplies
* `labelFor(code)`, keeping chip labels keyed off the stable code and never off the wire.
* @component ReviewTagSelector
*/
const ReviewTagSelector: FunctionComponent<ReviewTagSelectorProps> = ({
codes,
selected,
onChange,
labelFor,
disabled = false,
}) => {
const toggle = (code: string) => {
onChange(selected.includes(code) ? selected.filter((c) => c !== code) : [...selected, code]);
};
return (
<Box role="group" sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{codes.map((code) => {
const isSelected = selected.includes(code);
return (
<Chip
key={code}
label={labelFor(code)}
color={isSelected ? 'primary' : 'default'}
variant={isSelected ? 'filled' : 'outlined'}
clickable={!disabled}
disabled={disabled}
onClick={disabled ? undefined : () => toggle(code)}
aria-pressed={isSelected}
data-selected={isSelected ? 'true' : 'false'}
sx={{ fontWeight: isSelected ? 600 : 400 }}
/>
);
})}
</Box>
);
};
export default ReviewTagSelector;
@@ -0,0 +1,4 @@
import ReviewTagSelector from './ReviewTagSelector';
export type { ReviewTagSelectorProps } from './ReviewTagSelector';
export { ReviewTagSelector as default, ReviewTagSelector };
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import VisitNoteCard from './VisitNoteCard';
import type { VisitNote } from '@/services/patientRecords/types';
const NOTE: VisitNote = {
id: 8100,
bookingId: 5005,
nurseProfileId: 1,
nurseDisplayName: 'مریم رضایی',
body: 'وضعیت پایدار بود؛ داروها داده شد.',
taskResults: [
{ label: 'دادن متفورمین', done: true },
{ label: 'پیاده‌روی کوتاه', done: false },
],
recordedAt: '2026-06-01T09:00:00Z',
};
function renderCard(note: VisitNote = NOTE) {
return render(
<ThemeProvider>
<VisitNoteCard note={note} dateLabel="۱ خرداد ۱۴۰۵" authorFallback="پرستار" />
</ThemeProvider>,
);
}
describe('<VisitNoteCard/> component', () => {
it('renders the nurse name, date and body', () => {
renderCard();
expect(screen.getByText('مریم رضایی')).toBeInTheDocument();
expect(screen.getByText('۱ خرداد ۱۴۰۵')).toBeInTheDocument();
expect(screen.getByText('وضعیت پایدار بود؛ داروها داده شد.')).toBeInTheDocument();
});
it('renders each ticked task as a chip', () => {
renderCard();
expect(screen.getByText('دادن متفورمین')).toBeInTheDocument();
expect(screen.getByText('پیاده‌روی کوتاه')).toBeInTheDocument();
});
it('falls back to the provided author label when the nurse name is missing', () => {
renderCard({ ...NOTE, nurseDisplayName: null });
expect(screen.getByText('پرستار')).toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppIcon } from '@/components/common';
import type { VisitNote } from '@/services/patientRecords/types';
export interface VisitNoteCardProps {
/** One nurse-authored visit note (read-only from the client's perspective). */
note: VisitNote;
/** Pre-formatted Shamsi date/time — the caller owns locale. */
dateLabel: string;
/** Shown when the note has no recorded nurse name. */
authorFallback: string;
}
/**
* One read-only visit note in the longitudinal history — the nurse's display name, the Shamsi date, the note
* body, and (if any) the checklist the nurse ticked as done/not-done chips. Purely presentational (the caller
* formats the date and supplies the author fallback); reused by the customer سوابق tab and the nurse
* continuity view. Clinical text is rendered verbatim and never logged.
* @component VisitNoteCard
*/
const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel, authorFallback }) => {
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
<AppIcon icon="notes" size={18} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{note.nurseDisplayName?.trim() || authorFallback}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
{dateLabel}
</Typography>
</Stack>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{note.body}
</Typography>
{note.taskResults.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{note.taskResults.map((task, index) => (
<Chip
key={`${task.label}-${index}`}
size="small"
variant="outlined"
icon={
<AppIcon
icon={task.done ? 'verified' : 'pending'}
size={14}
color={task.done ? 'var(--bal-success)' : 'var(--bal-text-secondary)'}
/>
}
label={task.label}
sx={{ color: task.done ? undefined : 'text.secondary' }}
/>
))}
</Box>
) : null}
</Paper>
);
};
export default VisitNoteCard;
@@ -0,0 +1,4 @@
import VisitNoteCard from './VisitNoteCard';
export type { VisitNoteCardProps } from './VisitNoteCard';
export { VisitNoteCard as default, VisitNoteCard };
@@ -74,6 +74,12 @@ import LockIcon from '@mui/icons-material/LockOutlined';
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
// Payouts — the nurse earnings & payout-history surface (f12/b13)
import EarningsIcon from '@mui/icons-material/PaidOutlined';
// Reviews & patient care records (f13/b14): visit-note authoring, record tabs, family-ownership banner
import NotesIcon from '@mui/icons-material/NoteAltOutlined';
import RoutineIcon from '@mui/icons-material/EventRepeatOutlined';
import TasksIcon from '@mui/icons-material/ChecklistOutlined';
import HistoryIcon from '@mui/icons-material/HistoryOutlined';
import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined';
/**
* List of all available Icon names
@@ -156,4 +162,9 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
lock: LockIcon,
installments: InstallmentsIcon,
earnings: EarningsIcon,
notes: NotesIcon,
routine: RoutineIcon,
tasks: TasksIcon,
history: HistoryIcon,
family: FamilyIcon,
};
+12
View File
@@ -29,6 +29,10 @@ import InstallmentScheduleRow from './InstallmentScheduleRow';
import EarningsBalanceHeader from './EarningsBalanceHeader';
import EarningsRow from './EarningsRow';
import PayoutHistoryRow from './PayoutHistoryRow';
import RatingInput from './RatingInput';
import ReviewTagSelector from './ReviewTagSelector';
import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader';
export {
UserInfo,
@@ -60,6 +64,10 @@ export {
EarningsBalanceHeader,
EarningsRow,
PayoutHistoryRow,
RatingInput,
ReviewTagSelector,
VisitNoteCard,
PatientHeader,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -88,3 +96,7 @@ export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
export type { EarningsRowProps } from './EarningsRow';
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
export type { RatingInputProps } from './RatingInput';
export type { ReviewTagSelectorProps } from './ReviewTagSelector';
export type { VisitNoteCardProps } from './VisitNoteCard';
export type { PatientHeaderProps } from './PatientHeader';