frontend phase 13
This commit is contained in:
@@ -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, 0–max (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 1–max 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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user