frontend phase 13
This commit is contained in:
@@ -5,10 +5,11 @@ import { Stack, Typography } from '@mui/material';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { bookingCancelPath, bookingRefundStatusPath } from '@/constants';
|
||||
import { bookingCancelPath, bookingRefundStatusPath, bookingReviewPath } from '@/constants';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { useRefundStatus } from '@/services/refunds';
|
||||
import { isBookingCancellable } from '@/services/refunds/types';
|
||||
import { useMyReviewForBooking } from '@/services/reviews';
|
||||
|
||||
/**
|
||||
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
|
||||
@@ -28,6 +29,42 @@ export default function CustomerBookingDetailPage() {
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
|
||||
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
|
||||
{bookingId > 0 && <LeaveReviewCta bookingId={bookingId} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The f13 leave-a-review entry — reads the already-cached booking detail (no extra fetch) and only offers the
|
||||
* CTA once the booking is completed/closed. If the customer has already reviewed it, the CTA becomes a passive
|
||||
* "under review" affordance (the review is `pending_moderation` and never shown publicly here). The my-review
|
||||
* read is enabled only for a review-eligible booking, so an active booking triggers no reviews query.
|
||||
*/
|
||||
function LeaveReviewCta({ bookingId }: { bookingId: number }) {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('reviews');
|
||||
|
||||
const { data: booking } = useBookingDetail(bookingId, 'customer');
|
||||
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
|
||||
const { data: myReview } = useMyReviewForBooking(bookingId, { enabled: reviewable });
|
||||
|
||||
if (!booking || !reviewable) return null;
|
||||
|
||||
const alreadyReviewed = Boolean(myReview && myReview.status !== 'none');
|
||||
const underReview = myReview?.status === 'pending_moderation';
|
||||
|
||||
return (
|
||||
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<AppButton
|
||||
variant={alreadyReviewed ? 'outlined' : 'contained'}
|
||||
color="primary"
|
||||
startIcon="star"
|
||||
onClick={() => router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
|
||||
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
|
||||
|
||||
const REVIEW_BODY_MAX = 2000;
|
||||
|
||||
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
|
||||
const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
|
||||
pending_moderation: 'pending',
|
||||
published: 'verified',
|
||||
hidden: 'neutral',
|
||||
rejected: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* f13 — Leave a review («ثبت نظر»): the customer's one moderated review for a completed booking. The form is
|
||||
* shown only when the server says the booking can be reviewed AND it is not already reviewed; on submit it
|
||||
* flips to the persistent "under review" state (the review is `pending_moderation` and never appears publicly
|
||||
* here). One review per booking (1:1) — a returning customer sees their review's state, never a second form.
|
||||
*/
|
||||
export default function LeaveReviewPage() {
|
||||
const t = useTranslations('reviews');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
const rawId = Number(params.id);
|
||||
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
|
||||
|
||||
const { data: booking } = useBookingDetail(bookingId, 'customer');
|
||||
const eligibility = useReviewEligibility(bookingId);
|
||||
const myReview = useMyReviewForBooking(bookingId);
|
||||
const createReview = useCreateReview();
|
||||
|
||||
const [rating, setRating] = useState(0);
|
||||
const [body, setBody] = useState('');
|
||||
const [tagCodes, setTagCodes] = useState<string[]>([]);
|
||||
|
||||
const nurseName = booking?.nurseName?.trim();
|
||||
|
||||
const submit = () => {
|
||||
if (rating < 1) return;
|
||||
createReview.mutate(
|
||||
{ bookingId, body: { rating, body: body.trim() || null, tagCodes } },
|
||||
{ onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
|
||||
);
|
||||
};
|
||||
|
||||
// ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
|
||||
const existing = myReview.data;
|
||||
const submittedThisSession = createReview.isSuccess;
|
||||
if ((existing && existing.status !== 'none') || submittedThisSession) {
|
||||
const status: ModerationStatus =
|
||||
existing && existing.status !== 'none' ? existing.status : 'pending_moderation';
|
||||
const shownRating = existing && existing.status !== 'none' ? (existing.rating ?? rating) : rating;
|
||||
const shownBody = existing && existing.status !== 'none' ? existing.body : body.trim() || null;
|
||||
const shownTags = existing && existing.status !== 'none' ? existing.tagCodes : tagCodes;
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
|
||||
{existing?.createdAt ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(existing.createdAt, locale)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
{status === 'pending_moderation' ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('under_review_body')}
|
||||
</Typography>
|
||||
) : null}
|
||||
<RatingInput value={shownRating} readOnly size={22} ariaLabel={t('rating_label')} />
|
||||
{shownBody ? <Typography variant="body2">{shownBody}</Typography> : null}
|
||||
{shownTags.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{shownTags.map((code) => (
|
||||
<StatusChip key={code} status="info" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (eligibility.isLoading || myReview.isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<Skeleton variant="text" width="50%" height={36} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Not eligible → a clear, non-leaking reason (no form) ─────────────────────────────────────────────────
|
||||
if (!eligibility.data?.canReview) {
|
||||
const reason = eligibility.data?.reason ?? 'not_completed';
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('not_eligible_title')} />
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`reason_${reason}`)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('rating_label')}
|
||||
</Typography>
|
||||
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={t('body_label')}
|
||||
placeholder={t('body_placeholder')}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('tags_label')}
|
||||
</Typography>
|
||||
<ReviewTagSelector
|
||||
codes={REVIEW_TAG_CODES}
|
||||
selected={tagCodes}
|
||||
onChange={setTagCodes}
|
||||
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
|
||||
disabled={createReview.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" color="primary" onClick={() => router.back()} sx={{ m: 0 }} disabled={createReview.isPending}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={submit}
|
||||
disabled={rating < 1 || createReview.isPending}
|
||||
startIcon="star"
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{createReview.isPending ? t('submitting') : t('submit')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
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 type {
|
||||
CareRecordTab,
|
||||
CareTask,
|
||||
FamilyCareRecord,
|
||||
Medication,
|
||||
RoutineItem,
|
||||
} 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).
|
||||
*/
|
||||
export default function PatientRecordPage() {
|
||||
const t = useTranslations('records');
|
||||
// Shared enum labels are reused from the onboarding/patients namespaces — never re-keyed.
|
||||
const to = useTranslations('onboarding');
|
||||
const tp = useTranslations('patients');
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
const rawId = Number(params.id);
|
||||
const patientId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
|
||||
|
||||
const access = useRecordAccess(patientId);
|
||||
const canView = access.data?.canView ?? false;
|
||||
const patient = usePatient(patientId, { enabled: canView });
|
||||
|
||||
const [tab, setTab] = useState<CareRecordTab>('medications');
|
||||
|
||||
if (access.isLoading) return <RecordSkeleton />;
|
||||
|
||||
// Access-denied — a clear, non-leaking card (never any clinical data).
|
||||
if (access.data && !access.data.canView) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="lock" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1.5, mb: 0.5 }}>
|
||||
{t('access_denied_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('access_denied_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (patient.isLoading) return <RecordSkeleton />;
|
||||
|
||||
if (patient.isError || !patient.data) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{t('not_found_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('not_found_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const p = patient.data;
|
||||
const age = birthDateToAge(p.birthDate);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<PatientHeader
|
||||
displayName={p.displayName}
|
||||
relationLabel={p.relation ? to(`relation_${p.relation}`) : undefined}
|
||||
genderLabel={to(`gender_${p.gender}`)}
|
||||
ageLabel={age == null ? undefined : tp('age_years', { age })}
|
||||
conditionLabels={p.conditions.map((code) => to(`condition_${code}`))}
|
||||
noConditionsLabel={tp('conditions_none')}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 600 }}>
|
||||
{t('ownership_banner')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, next: CareRecordTab) => setTab(next)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
{CARE_RECORD_TABS.map((value) => (
|
||||
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none', fontWeight: 700 }} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{tab === 'history' ? (
|
||||
<HistoryTab patientId={patientId} />
|
||||
) : (
|
||||
<EditableTabs patientId={patientId} tab={tab} canEdit={access.data?.canEdit ?? false} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The three customer-editable tabs (medications/routine/tasks) share one record read + one save mutation. */
|
||||
function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: CareRecordTab; canEdit: boolean }) {
|
||||
const t = useTranslations('records');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const record = usePatientCareRecord(patientId);
|
||||
const update = useUpdateCareRecord(patientId);
|
||||
|
||||
if (record.isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (record.isError || !record.data) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('load_error')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const data = record.data;
|
||||
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
|
||||
update.mutate(patch, {
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
onDone();
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
});
|
||||
};
|
||||
|
||||
if (tab === 'medications') {
|
||||
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 <TasksTab data={data.tasks} canEdit={canEdit} saving={update.isPending} onSave={(tasks, done) => save({ tasks }, done)} />;
|
||||
}
|
||||
|
||||
// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
function MedicationsTab({
|
||||
data,
|
||||
canEdit,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
data: Medication[];
|
||||
canEdit: boolean;
|
||||
saving: boolean;
|
||||
onSave: (medications: Medication[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Medication[]>(data);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((m) => ({ ...m })));
|
||||
setEditing(true);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Routine ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
function RoutineTab({
|
||||
data,
|
||||
canEdit,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
data: RoutineItem[];
|
||||
canEdit: boolean;
|
||||
saving: boolean;
|
||||
onSave: (routine: RoutineItem[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<RoutineItem[]>(data);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((r) => ({ ...r })));
|
||||
setEditing(true);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
function TasksTab({
|
||||
data,
|
||||
canEdit,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
data: CareTask[];
|
||||
canEdit: boolean;
|
||||
saving: boolean;
|
||||
onSave: (tasks: CareTask[], onDone: () => void) => void;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<CareTask[]>(data);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(data.map((task) => ({ ...task })));
|
||||
setEditing(true);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History (سوابق) — patient-scoped, read-only, paged ────────────────────────────────────────────────────
|
||||
function HistoryTab({ patientId }: { patientId: number }) {
|
||||
const t = useTranslations('records');
|
||||
const locale = useLocale();
|
||||
const [page, setPage] = useState(1);
|
||||
const history = usePatientHistory(patientId, page);
|
||||
|
||||
if (history.isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (history.isError || !history.data) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('load_error')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, total, pageSize } = history.data;
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('history_empty')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((note) => (
|
||||
<VisitNoteCard key={note.id} note={note} dateLabel={formatShamsiDate(note.recordedAt, locale)} authorFallback={t('author_fallback')} />
|
||||
))}
|
||||
{totalPages > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<AppButton variant="text" color="primary" disabled={page <= 1 || history.isFetching} onClick={() => setPage((n) => Math.max(1, n - 1))} sx={{ m: 0 }}>
|
||||
{t('prev')}
|
||||
</AppButton>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_of', { page, total: totalPages })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" disabled={page >= totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)} sx={{ m: 0 }}>
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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={{ m: 0, 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={{ m: 0, 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} sx={{ m: 0 }}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave} sx={{ m: 0 }}>
|
||||
{saving ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToPatients() {
|
||||
const t = useTranslations('records');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back_to_patients')}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Skeleton variant="text" width="50%" height={32} />
|
||||
<Skeleton variant="text" width="30%" />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, 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';
|
||||
@@ -27,6 +29,8 @@ export default function PatientsPage() {
|
||||
const t = useTranslations('patients');
|
||||
const to = useTranslations('onboarding');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = usePatients();
|
||||
@@ -141,6 +145,8 @@ export default function PatientsPage() {
|
||||
ageLabel={age == null ? undefined : t('age_years', { age })}
|
||||
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
|
||||
noConditionsLabel={t('conditions_none')}
|
||||
onOpen={() => router.push(`/${locale}${patientRecordPath(patient.id)}`)}
|
||||
openLabel={t('open_record', { name: patient.displayName })}
|
||||
onEdit={() => openEdit(patient)}
|
||||
onArchive={() => setArchiveTarget(patient)}
|
||||
editLabel={t('edit')}
|
||||
|
||||
+116
-35
@@ -1,19 +1,24 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Avatar, Box, Chip, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, ServicePriceRow, TrustBadge } from '@/components';
|
||||
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useNurseProfile } from '@/services/search';
|
||||
import type { NurseProfile } from '@/services/search/types';
|
||||
import { useNurseReviews } from '@/services/reviews';
|
||||
import type { ReviewListItem } from '@/services/reviews/types';
|
||||
|
||||
type ProfileTab = 'services' | 'reviews';
|
||||
|
||||
/**
|
||||
* C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute
|
||||
* chips, the priced services list (ServicePriceRow), and the latest-review snippet. The primary CTA
|
||||
* "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` + city/category to the
|
||||
* f7 booking route (the form itself is DEFERRED → f7). States: loading skeleton, not-found, error/retry.
|
||||
* chips, and a **tabbed** body — «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
|
||||
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
|
||||
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
|
||||
*/
|
||||
export default function NurseProfilePage() {
|
||||
const t = useTranslations('search');
|
||||
@@ -26,6 +31,7 @@ export default function NurseProfilePage() {
|
||||
const { data: profile, isLoading, isError, error, refetch } = useNurseProfile(
|
||||
Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined,
|
||||
);
|
||||
const [tab, setTab] = useState<ProfileTab>('services');
|
||||
|
||||
if (isLoading) return <ProfileSkeleton />;
|
||||
|
||||
@@ -75,8 +81,13 @@ export default function NurseProfilePage() {
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<ProfileHeader profile={profile} />
|
||||
<AttributeChips profile={profile} />
|
||||
<ServicesSection profile={profile} />
|
||||
<LatestReview profile={profile} />
|
||||
|
||||
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
|
||||
<Tab value="reviews" label={t('tab_reviews')} sx={{ textTransform: 'none', fontWeight: 700 }} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
@@ -172,9 +183,6 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('services_title')}
|
||||
</Typography>
|
||||
{profile.services.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('services_empty')}
|
||||
@@ -196,39 +204,112 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LatestReview({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
/**
|
||||
* The f13 reviews tab — the aggregate rating + count and an infinite list of **published** reviews. Never
|
||||
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
|
||||
* recomputed value, not a client sum.
|
||||
*/
|
||||
function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
||||
const t = useTranslations('reviews');
|
||||
const locale = useLocale();
|
||||
const review = profile.latestReview;
|
||||
const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useNurseReviews(nurseId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="text" width="40%" />
|
||||
<Skeleton variant="rounded" height={88} />
|
||||
<Skeleton variant="rounded" height={88} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{t('load_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const aggregate = data?.pages[0]?.aggregate;
|
||||
const items = data?.pages.flatMap((page) => page.reviews.items) ?? [];
|
||||
const publishedCount = aggregate?.publishedCount ?? 0;
|
||||
|
||||
if (publishedCount === 0) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('reviews_empty')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const average = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(aggregate?.averageRating ?? 0);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('latest_review_title')}
|
||||
</Typography>
|
||||
{!review ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('no_reviews')}
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<RatingInput value={Math.round(aggregate?.averageRating ?? 0)} readOnly size={20} ariaLabel={t('rating_label')} />
|
||||
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
|
||||
{average}
|
||||
</Typography>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(review.rating)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{review.body}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('count', { count: publishedCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{hasNextPage ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
sx={{ m: 0, alignSelf: 'center' }}
|
||||
>
|
||||
{isFetchingNextPage ? t('loading') : t('load_more')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewCard({ review }: { review: ReviewListItem }) {
|
||||
const t = useTranslations('reviews');
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
|
||||
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
|
||||
{t('author_masked')} · {formatShamsiDate(review.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
|
||||
{review.tagCodes.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
|
||||
{review.tagCodes.map((code) => (
|
||||
<Chip key={code} size="small" variant="outlined" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, VisitNoteCard } from '@/components';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
|
||||
import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisitNote } from '@/services/patientRecords';
|
||||
import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
|
||||
import type { TaskResult } from '@/services/patientRecords/types';
|
||||
|
||||
/**
|
||||
* E3 (نمای پرستار) — the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking
|
||||
* detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits
|
||||
* ONE appended note. It exposes **no** medication/routine/task editing and never wires `updateCareRecord` —
|
||||
* append-only is a hard boundary. The nurse can read prior history for continuity (patient-scoped, so it
|
||||
* persists across nurse changes). The composer hides when the nurse lacks append access.
|
||||
*/
|
||||
export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number }) {
|
||||
const t = useTranslations('records');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const booking = useBookingDetail(bookingId, 'nurse');
|
||||
const status = booking.data?.status;
|
||||
const patientId = booking.data?.patientId ?? -1;
|
||||
const engaged = status ? isBookingConfirmedOrBeyond(status) : false;
|
||||
|
||||
const access = useRecordAccess(patientId, { enabled: engaged && patientId > 0 });
|
||||
const canAppend = access.data?.canAppendNote ?? false;
|
||||
const canView = access.data?.canView ?? false;
|
||||
|
||||
const record = usePatientCareRecord(patientId, { enabled: engaged && patientId > 0 && canAppend });
|
||||
const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
|
||||
const createNote = useCreateVisitNote(patientId);
|
||||
|
||||
const [note, setNote] = useState('');
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!booking.data || !engaged) return null;
|
||||
|
||||
const tasks = record.data?.tasks ?? [];
|
||||
|
||||
const submit = () => {
|
||||
if (!note.trim()) {
|
||||
enqueueSnackbar(t('note_required'), { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
|
||||
createNote.mutate(
|
||||
{ bookingId, body: note.trim(), taskResults },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('note_saved'), { variant: 'success' });
|
||||
setNote('');
|
||||
setChecked({});
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const historyItems = history.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
{canAppend ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('notes_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{record.isLoading || tasks.length > 0 ? (
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('tasks_checklist_title')}
|
||||
</Typography>
|
||||
{record.isLoading ? (
|
||||
<Skeleton variant="rounded" height={80} />
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<FormControlLabel
|
||||
key={task.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={Boolean(checked[task.id])}
|
||||
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label={task.label}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<TextField
|
||||
label={t('note_label')}
|
||||
placeholder={t('note_placeholder')}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon="notes"
|
||||
onClick={submit}
|
||||
disabled={createNote.isPending || !note.trim()}
|
||||
sx={{ m: 0, alignSelf: 'flex-end' }}
|
||||
>
|
||||
{createNote.isPending ? tc('saving') : t('note_submit')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{canView ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('continuity_title')}
|
||||
</Typography>
|
||||
{history.isLoading ? (
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
) : historyItems.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('history_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
historyItems.map((visitNote) => (
|
||||
<VisitNoteCard
|
||||
key={visitNote.id}
|
||||
note={visitNote}
|
||||
dateLabel={formatShamsiDate(visitNote.recordedAt, locale)}
|
||||
authorFallback={t('author_fallback')}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Stack } from '@mui/material';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
import NurseVisitNotesPanel from './NurseVisitNotesPanel';
|
||||
|
||||
/**
|
||||
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
|
||||
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
|
||||
* (two-stage disclosure). Reached from the ویزیت امروز day surface.
|
||||
* (two-stage disclosure). Below the EVV surface, the f13 append-only visit-note panel lets the nurse tick
|
||||
* today's task checklist, write a note, and read the patient's continuity history. Reached from the ویزیت
|
||||
* امروز day surface.
|
||||
*/
|
||||
export default function NurseBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="nurse" />;
|
||||
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BookingDetailView bookingId={bookingId} viewerRole="nurse" />
|
||||
{bookingId > 0 && <NurseVisitNotesPanel bookingId={bookingId} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -87,5 +87,13 @@ export const nursePayoutDetailPath = (payoutId: number | string): string =>
|
||||
export const nurseBookingDetailPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.NURSE_VISITS}/${bookingId}`;
|
||||
|
||||
/** The leave-a-review flow (f13) — the "ثبت نظر" CTA on a completed booking detail lands here. */
|
||||
export const bookingReviewPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/review`;
|
||||
|
||||
/** The E2 patient care-record viewer (f13) — reached by tapping a patient in the Patients tab. */
|
||||
export const patientRecordPath = (patientId: number | string): string =>
|
||||
`${ROUTES.PATIENTS}/${patientId}/record`;
|
||||
|
||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||
|
||||
@@ -263,6 +263,52 @@ function seed(): void {
|
||||
},
|
||||
],
|
||||
},
|
||||
// 5005 — a COMPLETED single-visit booking. There is otherwise no completed seed (checkOutVisit only
|
||||
// reaches `completed` at runtime), so f13's leave-a-review flow needs this: the customer opens 5005 →
|
||||
// review-eligible; its patient 905 + nurse 1 align with the reviews/records mocks for deep-links.
|
||||
{
|
||||
id: 5005,
|
||||
bookingRequestId: 9005,
|
||||
status: 'completed',
|
||||
nurseId: NURSE_ID,
|
||||
nurseName: NURSE_NAME,
|
||||
patientId: 905,
|
||||
patientName: 'بانو حسینی',
|
||||
variantId: 15,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی — ویزیت', priceUnit: 'per_visit' }),
|
||||
customerAddressId: 805,
|
||||
addressSnapshotJson: JSON.stringify({ title: 'خانه', addressLine: 'تهران، خیابان ولیعصر', cityName: 'تهران', latitude: 35.72, longitude: 51.41 }),
|
||||
grossPriceIrr: '3000000',
|
||||
balinyaarCommissionIrr: '360000',
|
||||
nursePayoutAmount: '2640000',
|
||||
pspFeeAmount: '60000',
|
||||
platformFeeRate: 0.12,
|
||||
sessionCount: 1,
|
||||
scheduledDate: isoDate(-2),
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
confirmedAt: new Date(Date.now() - 4 * 86_400_000).toISOString(),
|
||||
completedAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
cancellationReason: null,
|
||||
cancellationPolicyCode: null,
|
||||
cancellationRefundPercentage: null,
|
||||
refundableAmountIrr: null,
|
||||
disputeWindowEndsAt: new Date(Date.now() + 2 * 86_400_000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
|
||||
sessions: [
|
||||
{
|
||||
...makeSession(70051, 1, -2, '2640000'),
|
||||
status: 'completed',
|
||||
payoutEligibleAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
|
||||
evvStatus: 'completed',
|
||||
checkInAt: new Date(Date.now() - 1 * 86_400_000 - 4 * 3_600_000).toISOString(),
|
||||
checkOutAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
|
||||
checkInAddressMatch: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
care[5001] = {
|
||||
@@ -575,6 +621,16 @@ export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
|
||||
return cloneBooking(findBooking(bookingId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-only read for the reviews domain (f13): the booking (a safe clone) so the reviews mock can gate
|
||||
* review-eligibility on a completed/closed booking and read `nurseId`/`patientId` for a submission. Throws
|
||||
* `404` if unknown. One-way edge INTO bookings (the bookings mock never imports f13 back → no cycle). NOT
|
||||
* part of the `BookingsApi` seam — only `services/reviews`' mock imports it.
|
||||
*/
|
||||
export function mockGetBookingForReview(bookingId: number): BookingDetailDto {
|
||||
return cloneBooking(findBooking(bookingId));
|
||||
}
|
||||
|
||||
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
|
||||
export interface CancelBookingSnapshot {
|
||||
cancelledBy: string;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { RECORD_HISTORY_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CreateVisitNoteRequest,
|
||||
FamilyCareRecord,
|
||||
PatientRecordsApi,
|
||||
RecordAccess,
|
||||
TaskResult,
|
||||
UpdateFamilyRecordRequest,
|
||||
VisitNote,
|
||||
WriteVisitNoteResult,
|
||||
} from '../types';
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
/** Wire `CareRecordDto` — the nurse-authored note (`body` decrypted after the access check). */
|
||||
interface CareRecordWire {
|
||||
id: number;
|
||||
patientId: number;
|
||||
bookingId: number | null;
|
||||
nurseProfileId: number;
|
||||
nurseName: string | null;
|
||||
body: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
function toVisitNote(w: CareRecordWire): VisitNote {
|
||||
return {
|
||||
id: w.id,
|
||||
bookingId: w.bookingId,
|
||||
nurseProfileId: w.nurseProfileId,
|
||||
nurseDisplayName: w.nurseName,
|
||||
body: w.body,
|
||||
// The wire body carries only free text; the structured checklist is composed into it on write (see below).
|
||||
taskResults: [],
|
||||
recordedAt: w.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds the nurse's ticked task checklist into the free-text note body, because the wire
|
||||
* `WriteCareRecordBody` has only `{ bookingId?, body }` — there is no structured task field (REQ-027 would
|
||||
* add one). The mock keeps `taskResults` structured; the real path serialises them as a leading summary line.
|
||||
*/
|
||||
export function composeVisitNoteBody(body: string, taskResults: TaskResult[] | undefined): string {
|
||||
const trimmed = body.trim();
|
||||
if (!taskResults || taskResults.length === 0) return trimmed;
|
||||
const summary = taskResults.map((t) => `${t.done ? '✓' : '✗'} ${t.label}`).join(' · ');
|
||||
return trimmed ? `${summary}\n\n${trimmed}` : summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Two methods map **published**
|
||||
* b14 routes:
|
||||
* - `getPatientHistory` → `GET patients/{id}/care_records` (the patient-scoped, newest-first note history;
|
||||
* a `403` from the envelope surfaces as an `ApiError` the E2 screen renders as access-denied).
|
||||
* - `createVisitNote` → `POST patients/{id}/care_records` (a nurse appends one encrypted note).
|
||||
*
|
||||
* The family-record + access methods target contract gaps the frontend filed (**REQ-027**) — no wire
|
||||
* endpoint exists — which is why the domain stays mock-primary (see `constants.ts`).
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
|
||||
*/
|
||||
export const patientRecordsClientApi: PatientRecordsApi = {
|
||||
// REQ-027: proposed owner/nurse-scoped read of the family-owned record (no wire endpoint yet).
|
||||
getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`)),
|
||||
|
||||
// REQ-027: proposed access check. On the real path the 403 on the history read is the true access signal.
|
||||
getRecordAccess: async (patientId: number): Promise<RecordAccess> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)),
|
||||
|
||||
getPatientHistory: async (patientId: number, params: PageParams): Promise<Paginated<VisitNote>> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? RECORD_HISTORY_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<CareRecordWire>>>(
|
||||
`${API}/patients/${patientId}/care_records?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return { ...page, items: page.items.map(toVisitNote) };
|
||||
},
|
||||
|
||||
// REQ-027: proposed customer edit of the family record.
|
||||
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<WriteVisitNoteResult>>(`${API}/patients/${patientId}/care_records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bookingId: body.bookingId ?? null,
|
||||
body: composeVisitNoteBody(body.body, body.taskResults),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { USE_PATIENT_RECORDS_MOCK } from '../constants';
|
||||
import type { PatientRecordsApi } from '../types';
|
||||
import { patientRecordsClientApi } from './clientApi';
|
||||
import { patientRecordsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `PatientRecordsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_PATIENT_RECORDS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (the
|
||||
* family-owned record + access check are REQ-027 gaps; the visit-note history/append are real b14).
|
||||
*/
|
||||
export const patientRecordsApi: PatientRecordsApi = USE_PATIENT_RECORDS_MOCK
|
||||
? patientRecordsMockApi
|
||||
: patientRecordsClientApi;
|
||||
@@ -0,0 +1,195 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { MOCK_FOREIGN_PATIENT_ID } from '../constants';
|
||||
import type {
|
||||
CreateVisitNoteRequest,
|
||||
FamilyCareRecord,
|
||||
Medication,
|
||||
PatientRecordsApi,
|
||||
RecordAccess,
|
||||
RoutineItem,
|
||||
UpdateFamilyRecordRequest,
|
||||
VisitNote,
|
||||
WriteVisitNoteResult,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* In-memory `PatientRecordsApi` — **the primary implementation this phase** (the nurse-authored visit-note
|
||||
* history/append are real b14, but the family-owned medications/routine/tasks record + the access check are
|
||||
* REQ-027 gaps — see `constants.ts`).
|
||||
*
|
||||
* The store is **patient-scoped** and lazily seeds a coherent default the first time any patient is read, so
|
||||
* every E2 record viewer has content and every state is demoable:
|
||||
* - a **default family record** (medications/routine/tasks the customer edits);
|
||||
* - a **multi-nurse continuity history** (two prior notes from *different* nurses — proving the history
|
||||
* persists across nurse changes; a nurse append prepends to the SAME patient's history);
|
||||
* - a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID` → `canView: false` + a `403` on
|
||||
* every read) so the non-leaking access-denied card is demoable.
|
||||
*
|
||||
* Money-free domain; clinical text is fixture data (never logged). Patient ids align with the f8 booking
|
||||
* snapshots (patient 905 on the completed booking 5005) so the nurse note flow lands on a real record.
|
||||
*/
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
/** The seeded nurse authoring a fresh note in the mock (the current session's nurse). */
|
||||
const MOCK_CURRENT_NURSE_ID = 1;
|
||||
const MOCK_CURRENT_NURSE_NAME = 'مریم رضایی';
|
||||
|
||||
let nextNoteId = 8100;
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
return new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
/** The default family-owned record seeded for a patient on first access (the customer edits from here). */
|
||||
function defaultFamilyRecord(patientId: number): FamilyCareRecord {
|
||||
return {
|
||||
patientId,
|
||||
medications: [
|
||||
{ id: 'm1', name: 'متفورمین ۵۰۰', dosage: '۱ قرص', frequency: 'روزی دو بار', timingNote: 'صبح و شب، بعد از غذا' },
|
||||
{ id: 'm2', name: 'لوزارتان ۲۵', dosage: '۱ قرص', frequency: 'روزی یک بار', timingNote: 'صبح' },
|
||||
],
|
||||
routine: [
|
||||
{ id: 'r1', label: 'اندازهگیری فشار خون', timeOfDay: 'صبح', note: 'پیش از داروی فشار' },
|
||||
{ id: 'r2', label: 'پیادهروی کوتاه', timeOfDay: 'عصر', note: null },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 't1', label: 'دادن متفورمین', done: false },
|
||||
{ id: 't2', label: 'اندازهگیری فشار خون', done: false },
|
||||
{ id: 't3', label: 'پیادهروی کوتاه', done: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A default continuity history seeded for a patient — two notes from DIFFERENT nurses (newest-first). */
|
||||
function defaultHistory(): VisitNote[] {
|
||||
return [
|
||||
{
|
||||
id: nextNoteId++,
|
||||
bookingId: null,
|
||||
nurseProfileId: 2,
|
||||
nurseDisplayName: 'سارا محمدی',
|
||||
body: 'وضعیت بیمار پایدار بود؛ داروها طبق برنامه داده شد و فشار خون در محدودهٔ طبیعی بود.',
|
||||
taskResults: [
|
||||
{ label: 'دادن متفورمین', done: true },
|
||||
{ label: 'اندازهگیری فشار خون', done: true },
|
||||
],
|
||||
recordedAt: isoDaysAgo(2),
|
||||
},
|
||||
{
|
||||
id: nextNoteId++,
|
||||
bookingId: null,
|
||||
nurseProfileId: MOCK_CURRENT_NURSE_ID,
|
||||
nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
|
||||
body: 'پیادهروی کوتاه انجام شد؛ اشتها خوب بود. توصیه به ادامهٔ روتین.',
|
||||
taskResults: [{ label: 'پیادهروی کوتاه', done: true }],
|
||||
recordedAt: isoDaysAgo(9),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const familyRecords = new Map<number, FamilyCareRecord>();
|
||||
const histories = new Map<number, VisitNote[]>();
|
||||
|
||||
function ensureFamilyRecord(patientId: number): FamilyCareRecord {
|
||||
let record = familyRecords.get(patientId);
|
||||
if (!record) {
|
||||
record = defaultFamilyRecord(patientId);
|
||||
familyRecords.set(patientId, record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function ensureHistory(patientId: number): VisitNote[] {
|
||||
let history = histories.get(patientId);
|
||||
if (!history) {
|
||||
history = defaultHistory();
|
||||
histories.set(patientId, history);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
/** Deep clone so a reader can't mutate the store by reference. */
|
||||
function cloneRecord(r: FamilyCareRecord): FamilyCareRecord {
|
||||
return {
|
||||
patientId: r.patientId,
|
||||
medications: r.medications.map((m) => ({ ...m })),
|
||||
routine: r.routine.map((x) => ({ ...x })),
|
||||
tasks: r.tasks.map((t) => ({ ...t })),
|
||||
};
|
||||
}
|
||||
|
||||
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.max(1, params.pageSize ?? all.length);
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||
}
|
||||
|
||||
function assertAccess(patientId: number): void {
|
||||
// The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
|
||||
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
|
||||
throw new ApiError(403, 'No clinical access to this patient', 'no_access');
|
||||
}
|
||||
}
|
||||
|
||||
export const patientRecordsMockApi: PatientRecordsApi = {
|
||||
getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
assertAccess(patientId);
|
||||
return cloneRecord(ensureFamilyRecord(patientId));
|
||||
},
|
||||
|
||||
getRecordAccess: async (patientId: number): Promise<RecordAccess> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
|
||||
return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'no_access' };
|
||||
}
|
||||
// In the single-session mock, an authorized viewer can do everything; the SCREEN (customer vs nurse
|
||||
// shell) decides which affordances to render — the nurse view never wires the edit path (append-only).
|
||||
return { canView: true, canEdit: true, canAppendNote: true };
|
||||
},
|
||||
|
||||
getPatientHistory: async (patientId: number, params: PageParams): Promise<Paginated<VisitNote>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
assertAccess(patientId);
|
||||
const history = [...ensureHistory(patientId)].sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
|
||||
return paginate(
|
||||
history.map((n) => ({ ...n, taskResults: n.taskResults.map((t) => ({ ...t })) })),
|
||||
params,
|
||||
);
|
||||
},
|
||||
|
||||
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
assertAccess(patientId);
|
||||
const record = ensureFamilyRecord(patientId);
|
||||
if (body.medications) record.medications = body.medications.map((m: Medication) => ({ ...m }));
|
||||
if (body.routine) record.routine = body.routine.map((r: RoutineItem) => ({ ...r }));
|
||||
if (body.tasks) record.tasks = body.tasks.map((t) => ({ ...t }));
|
||||
return cloneRecord(record);
|
||||
},
|
||||
|
||||
createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
assertAccess(patientId);
|
||||
const trimmed = body.body.trim();
|
||||
if (!trimmed) throw new ApiError(400, 'Note body is required', 'empty_body');
|
||||
|
||||
const id = nextNoteId++;
|
||||
const recordedAt = new Date().toISOString();
|
||||
const note: VisitNote = {
|
||||
id,
|
||||
bookingId: body.bookingId ?? null,
|
||||
nurseProfileId: MOCK_CURRENT_NURSE_ID,
|
||||
nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
|
||||
body: trimmed,
|
||||
taskResults: (body.taskResults ?? []).map((t) => ({ ...t })),
|
||||
recordedAt,
|
||||
};
|
||||
ensureHistory(patientId).unshift(note);
|
||||
return { id, patientId, recordedAt };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `PatientRecordsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records`
|
||||
* GET/POST) — those two methods are real — but the **family-owned editable record** (medications/routine/
|
||||
* tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has
|
||||
* them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient,
|
||||
* enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the
|
||||
* history. Flip to `false` once REQ-027 lands — only `clientApi.ts`'s family-record/access methods flip; the
|
||||
* history/append methods already map the real routes.
|
||||
*/
|
||||
export const USE_PATIENT_RECORDS_MOCK = true;
|
||||
|
||||
/** Page size for the longitudinal visit-note history (api-conventions `pageSize`). */
|
||||
export const RECORD_HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* The family record + history move slowly (a customer edit / a per-visit note), so a modest `staleTime` means
|
||||
* tab-switching never refetches; mutations invalidate the affected key. The access check is effectively
|
||||
* static per session — keep it warm longer.
|
||||
*/
|
||||
export const FAMILY_RECORD_STALE_TIME = 60 * 1000;
|
||||
export const RECORD_HISTORY_STALE_TIME = 60 * 1000;
|
||||
export const RECORD_ACCESS_STALE_TIME = 5 * 60 * 1000;
|
||||
export const PATIENT_RECORDS_GC_TIME = 10 * 60 * 1000;
|
||||
|
||||
/** Wire cap on a visit-note body (`WriteCareRecordBody.body` ≤ 8000). */
|
||||
export const VISIT_NOTE_MAX_LENGTH = 8000;
|
||||
|
||||
/**
|
||||
* A sentinel patient id the mock treats as **not owned** by the caller, so the E2 access-denied card is
|
||||
* demoable by navigating to `/patients/8888/record`. On the real path this state comes from a `403` on the
|
||||
* clinical read; there is no such thing as a "foreign patient id" on the wire.
|
||||
*/
|
||||
export const MOCK_FOREIGN_PATIENT_ID = 8888;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { patientRecordsApi } from '../apis';
|
||||
import { recordKeys } from '../keys';
|
||||
import type { CreateVisitNoteRequest, WriteVisitNoteResult } from '../types';
|
||||
|
||||
/**
|
||||
* The **nurse** append of a visit note (append-only — the nurse never edits the family record). Bound to one
|
||||
* patient. On success we invalidate the patient's **history** (every page) so the appended note appears in the
|
||||
* longitudinal timeline at once. The mutation returns only the write result; the history refetch is the source
|
||||
* of truth. Domain 4xx (`400` empty body, `403` no clinical access) surface to the caller's `onError`.
|
||||
*/
|
||||
export function useCreateVisitNote(patientId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<WriteVisitNoteResult, unknown, CreateVisitNoteRequest>({
|
||||
mutationFn: (body) => patientRecordsApi.createVisitNote(patientId, body),
|
||||
onSuccess: () => {
|
||||
// Prefix invalidation: [...histories(), patientId] matches every page of this patient's history.
|
||||
queryClient.invalidateQueries({ queryKey: [...recordKeys.histories(), patientId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { patientRecordsApi } from '../apis';
|
||||
import { recordKeys } from '../keys';
|
||||
import { FAMILY_RECORD_STALE_TIME, PATIENT_RECORDS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The family-owned, patient-scoped editable record (medications/routine/tasks). Keyed per patient; a customer
|
||||
* edit invalidates this key. A `403` (access-denied) surfaces as the query's error — the E2 screen gates on
|
||||
* `useRecordAccess` first, so this normally only runs for an authorized viewer.
|
||||
*/
|
||||
export function usePatientCareRecord(patientId: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: recordKeys.patient(patientId),
|
||||
queryFn: () => patientRecordsApi.getFamilyRecord(patientId),
|
||||
enabled: (options?.enabled ?? true) && patientId > 0,
|
||||
staleTime: FAMILY_RECORD_STALE_TIME,
|
||||
gcTime: PATIENT_RECORDS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { patientRecordsApi } from '../apis';
|
||||
import { recordKeys } from '../keys';
|
||||
import { PATIENT_RECORDS_GC_TIME, RECORD_HISTORY_PAGE_SIZE, RECORD_HISTORY_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The patient-scoped longitudinal visit-note history (newest-first, paged) — REAL b14 `care_records` GET.
|
||||
* Read-only; it **persists across nurse changes** (keyed to the patient, not a booking). The page is part of
|
||||
* the query key so paging never refetches a page already in cache; `keepPreviousData` avoids an empty flash.
|
||||
* A nurse note append invalidates these keys so the new note appears at once.
|
||||
*/
|
||||
export function usePatientHistory(patientId: number, page: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: recordKeys.history(patientId, page),
|
||||
queryFn: () => patientRecordsApi.getPatientHistory(patientId, { page, pageSize: RECORD_HISTORY_PAGE_SIZE }),
|
||||
enabled: (options?.enabled ?? true) && patientId > 0,
|
||||
staleTime: RECORD_HISTORY_STALE_TIME,
|
||||
gcTime: PATIENT_RECORDS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { patientRecordsApi } from '../apis';
|
||||
import { recordKeys } from '../keys';
|
||||
import { PATIENT_RECORDS_GC_TIME, RECORD_ACCESS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* Who may view/edit/append this patient's record. The E2 viewer gates on `canView` **before** fetching the
|
||||
* record/history, so an unauthorized viewer never pulls clinical data (the two-stage-disclosure discipline —
|
||||
* the access decision lives here, not in the presentational card). Effectively static per session.
|
||||
*/
|
||||
export function useRecordAccess(patientId: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: recordKeys.access(patientId),
|
||||
queryFn: () => patientRecordsApi.getRecordAccess(patientId),
|
||||
enabled: (options?.enabled ?? true) && patientId > 0,
|
||||
staleTime: RECORD_ACCESS_STALE_TIME,
|
||||
gcTime: PATIENT_RECORDS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { patientRecordsApi } from '../apis';
|
||||
import { recordKeys } from '../keys';
|
||||
import type { FamilyCareRecord, UpdateFamilyRecordRequest } from '../types';
|
||||
|
||||
/**
|
||||
* The **customer** edit of the family-owned record (medications/routine/tasks). Bound to one patient. On
|
||||
* success we write the returned record straight into the cache (`setQueryData`) so the edit shows without a
|
||||
* refetch flash. This hook is **customer-only** — it must **never** be wired into the nurse view (the nurse is
|
||||
* append-only).
|
||||
*/
|
||||
export function useUpdateCareRecord(patientId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<FamilyCareRecord, unknown, UpdateFamilyRecordRequest>({
|
||||
mutationFn: (body) => patientRecordsApi.updateFamilyRecord(patientId, body),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(recordKeys.patient(patientId), updated);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Patient-records domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
|
||||
* types/keys/apis directly from their files when needed.
|
||||
*
|
||||
* NB `useUpdateCareRecord` is **customer-only** and `useCreateVisitNote` is **nurse-only** — the nurse view
|
||||
* must never import the former (append-only is a hard boundary, not a hidden button).
|
||||
*/
|
||||
export { usePatientCareRecord } from './hooks/usePatientCareRecord';
|
||||
export { useRecordAccess } from './hooks/useRecordAccess';
|
||||
export { usePatientHistory } from './hooks/usePatientHistory';
|
||||
export { useUpdateCareRecord } from './hooks/useUpdateCareRecord';
|
||||
export { useCreateVisitNote } from './hooks/useCreateVisitNote';
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* React Query key factory for the patient-records domain (hierarchical, per the `services/{domain}` pattern).
|
||||
*
|
||||
* Every key is **patient-scoped** (the record is keyed to the patient, not a booking). The family record,
|
||||
* the access check, and each history page key independently so revisiting a tab never refetches. A customer
|
||||
* edit invalidates `patient(patientId)`; a nurse note append invalidates the patient's `history` **only** —
|
||||
* the append is append-only and does not mutate the family record, so `patient` stays validly cached.
|
||||
*/
|
||||
export const recordKeys = {
|
||||
all: ['patient_records'] as const,
|
||||
|
||||
patients: () => [...recordKeys.all, 'patient'] as const,
|
||||
/** The family-owned medications/routine/tasks record. */
|
||||
patient: (patientId: number) => [...recordKeys.patients(), patientId] as const,
|
||||
|
||||
access: (patientId: number) => [...recordKeys.all, 'access', patientId] as const,
|
||||
|
||||
histories: () => [...recordKeys.all, 'history'] as const,
|
||||
history: (patientId: number, page: number) => [...recordKeys.histories(), patientId, page] as const,
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Patient care-records domain — the **continuity-of-care** surface (b14). Two very different things share
|
||||
* one screen (the E2 record viewer):
|
||||
*
|
||||
* 1. **The nurse-authored, patient-scoped visit-note history** (سوابق) — **REAL** (b14 `care_records`
|
||||
* GET/POST). Encrypted at rest, returned decrypted only after the clinical-access check passes; it is
|
||||
* **patient-scoped, not booking-scoped**, so a new nurse taking over reads the whole history. A nurse
|
||||
* with a qualifying booking may **append** a note; nobody edits it.
|
||||
* 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend
|
||||
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it;
|
||||
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`).
|
||||
*
|
||||
* Load-bearing rules (contract + phase §5):
|
||||
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
|
||||
* persists across nurse changes (keyed to the patient, not the booking).
|
||||
* - **Nurse is append-only.** The nurse view exposes the task checklist + a note composer + the read-only
|
||||
* history — it must **never** wire `updateFamilyRecord` or any medication/routine/task editing.
|
||||
* - **Strict access.** Owning customer / nurse with a confirmed booking / admin only. A `403` on a read →
|
||||
* render a clear, non-leaking access-denied card (never partial clinical data).
|
||||
* - **Clinical fields are sensitive** — never logged, never in `localStorage`, never in a query string.
|
||||
*
|
||||
* Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` + swagger); the
|
||||
* family-record shapes are the client's own (REQ-027).
|
||||
*/
|
||||
|
||||
/** The four tabs of the E2 record viewer (client display model). */
|
||||
export type CareRecordTab = 'medications' | 'routine' | 'history' | 'tasks';
|
||||
export const CARE_RECORD_TABS: readonly CareRecordTab[] = ['medications', 'routine', 'history', 'tasks'] as const;
|
||||
|
||||
// ── Family-owned editable record (REQ-027 — customer-maintained, no backend) ──────────────────────────────
|
||||
|
||||
/** A medication the family tracks. `id` is a stable client id (the record has no server identity yet). */
|
||||
export interface Medication {
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string | null;
|
||||
frequency: string;
|
||||
timingNote: string | null;
|
||||
}
|
||||
|
||||
/** A daily-routine item the family tracks (e.g. "measure blood pressure — morning"). */
|
||||
export interface RoutineItem {
|
||||
id: string;
|
||||
label: string;
|
||||
timeOfDay: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** A care task (the checklist the nurse ticks during a visit; the family authors the list). */
|
||||
export interface CareTask {
|
||||
id: string;
|
||||
label: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** The family-owned, patient-scoped editable record (REQ-027). */
|
||||
export interface FamilyCareRecord {
|
||||
patientId: number;
|
||||
medications: Medication[];
|
||||
routine: RoutineItem[];
|
||||
tasks: CareTask[];
|
||||
}
|
||||
|
||||
// ── Nurse-authored visit-note history (REAL — b14 care_records) ───────────────────────────────────────────
|
||||
|
||||
/** A structured "task X done/not-done" line the nurse ticked — **client-only** (the wire body is free text). */
|
||||
export interface TaskResult {
|
||||
label: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One nurse-authored visit note = one `CareRecordDto` (patient-scoped, newest-first). Read-only/append-only
|
||||
* from the client. `taskResults` is a client-only structured summary of the checklist the nurse ticked — the
|
||||
* wire `body` carries only free text, so on the real path the checklist is folded into `body`.
|
||||
*/
|
||||
export interface VisitNote {
|
||||
id: number;
|
||||
bookingId: number | null;
|
||||
nurseProfileId: number;
|
||||
nurseDisplayName: string | null;
|
||||
body: string;
|
||||
taskResults: TaskResult[];
|
||||
/** UTC ISO-8601 — Shamsi display is the client's job. */
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ─────────────────
|
||||
|
||||
export type RecordAccessDeniedReason = 'no_access' | 'not_found';
|
||||
|
||||
/**
|
||||
* Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a
|
||||
* nurse with a qualifying booking only. A denied read surfaces `canView: false` + a `deniedReason`.
|
||||
*/
|
||||
export interface RecordAccess {
|
||||
canView: boolean;
|
||||
canEdit: boolean;
|
||||
canAppendNote: boolean;
|
||||
deniedReason?: RecordAccessDeniedReason;
|
||||
}
|
||||
|
||||
/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */
|
||||
export interface UpdateFamilyRecordRequest {
|
||||
medications?: Medication[];
|
||||
routine?: RoutineItem[];
|
||||
tasks?: CareTask[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The nurse append body. Maps to the wire `WriteCareRecordBody` (`{ bookingId?, body }`): on the real path
|
||||
* `taskResults` is composed into `body` (the wire has no structured task field); the mock keeps it structured.
|
||||
*/
|
||||
export interface CreateVisitNoteRequest {
|
||||
bookingId?: number | null;
|
||||
body: string;
|
||||
taskResults?: TaskResult[];
|
||||
}
|
||||
|
||||
/** `WriteCareRecordResult` — the append result. */
|
||||
export interface WriteVisitNoteResult {
|
||||
id: number;
|
||||
patientId: number;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patient-records API seam — the real HTTP client and the in-memory mock both implement this; selection
|
||||
* is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. `getPatientHistory` +
|
||||
* `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked).
|
||||
*/
|
||||
export interface PatientRecordsApi {
|
||||
/** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */
|
||||
getFamilyRecord(patientId: number): Promise<FamilyCareRecord>;
|
||||
/** REQ-027 — who may view/edit/append for this patient (derived from the 403 on a read + the caller role). */
|
||||
getRecordAccess(patientId: number): Promise<RecordAccess>;
|
||||
/** REAL — the patient-scoped longitudinal visit-note history, newest-first, paged. */
|
||||
getPatientHistory(patientId: number, params: PageParams): Promise<Paginated<VisitNote>>;
|
||||
/** REQ-027 — the customer replaces sections of the family record. */
|
||||
updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord>;
|
||||
/** REAL — a nurse appends a visit note (append-only; never edits the record). */
|
||||
createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import { PATIENTS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* A single patient by id — the identity header for the E2 record viewer (name, age/gender, conditions).
|
||||
* Keyed on `patientKeys.detail(id)` so it shares/warms the same cache the list primes. A missing/cross-tenant
|
||||
* id `404`s (surfaced as the query error); the caller renders a not-found state. `enabled` lets the E2 viewer
|
||||
* defer the fetch until the clinical-access check passes.
|
||||
*/
|
||||
export function usePatient(id: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: patientKeys.detail(id),
|
||||
queryFn: () => patientsApi.get(id),
|
||||
enabled: (options?.enabled ?? true) && id > 0,
|
||||
staleTime: PATIENTS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { usePatients } from './hooks/usePatients';
|
||||
export { usePatient } from './hooks/usePatient';
|
||||
export { useCreatePatient } from './hooks/useCreatePatient';
|
||||
export { useUpdatePatient } from './hooks/useUpdatePatient';
|
||||
export { useArchivePatient } from './hooks/useArchivePatient';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { REVIEWS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CreateReviewRequest,
|
||||
MyReviewState,
|
||||
NurseReviews,
|
||||
ReviewEligibility,
|
||||
ReviewListItem,
|
||||
ReviewsApi,
|
||||
SubmitReviewResult,
|
||||
} from '../types';
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
/** Wire `NurseReviewsResult` — `reviews` is a `PagedResult<ReviewListItemDto>` (camelCase, per api-conventions). */
|
||||
interface NurseReviewsWire {
|
||||
aggregate: { averageRating: number; publishedCount: number };
|
||||
reviews: Paginated<ReviewListItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
|
||||
* swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes:
|
||||
* - `getNurseReviews` → `GET nurses/{id}/reviews` (aggregate + published page; server filters to published).
|
||||
* - `createReview` → `POST bookings/{id}/review` (the one review per completed booking; `409` if reviewed).
|
||||
*
|
||||
* The other two target contract gaps the frontend filed (**REQ-026**), which is why the domain stays
|
||||
* mock-primary (see `constants.ts`):
|
||||
* - `getReviewEligibility` → whether this booking can still be reviewed (no wire read; proposed slug below).
|
||||
* - `getMyReviewForBooking` → the caller's own review + its moderation state (no wire read; proposed slug).
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_REVIEWS_MOCK = true`). `clientFetch` returns the raw
|
||||
* envelope so we `unwrap()`; ids come from the route; list params are camelCase (`page`/`pageSize`).
|
||||
*/
|
||||
export const reviewsClientApi: ReviewsApi = {
|
||||
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE));
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<NurseReviewsWire>>(
|
||||
`${API}/nurses/${nurseProfileId}/reviews?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return { aggregate: wire.aggregate, reviews: wire.reviews };
|
||||
},
|
||||
|
||||
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while
|
||||
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip.
|
||||
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)),
|
||||
|
||||
// REQ-026: proposed owner-scoped read of the caller's own review for this booking.
|
||||
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)),
|
||||
|
||||
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<SubmitReviewResult>>(`${API}/bookings/${bookingId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { USE_REVIEWS_MOCK } from '../constants';
|
||||
import type { ReviewsApi } from '../types';
|
||||
import { reviewsClientApi } from './clientApi';
|
||||
import { reviewsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `ReviewsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_REVIEWS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-026 gaps +
|
||||
* admin-only moderation).
|
||||
*/
|
||||
export const reviewsApi: ReviewsApi = USE_REVIEWS_MOCK ? reviewsMockApi : reviewsClientApi;
|
||||
@@ -0,0 +1,181 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi';
|
||||
import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants';
|
||||
import type {
|
||||
CreateReviewRequest,
|
||||
ModerationStatus,
|
||||
MyReviewState,
|
||||
NurseReviews,
|
||||
ReviewEligibility,
|
||||
ReviewListItem,
|
||||
ReviewsApi,
|
||||
SubmitReviewResult,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* In-memory `ReviewsApi` — **the primary implementation this phase** (b14 serves submit + the public list,
|
||||
* but review-eligibility and my-review-for-booking are REQ-026 gaps and moderation is admin-only/f15 — see
|
||||
* `constants.ts`).
|
||||
*
|
||||
* It is engineered to demo the whole trust loop end-to-end:
|
||||
* - **Published seed** — a per-nurse published-review list (nurse 1 has 7 so the profile tab paginates;
|
||||
* nurses 5/6 have none → the empty state). The aggregate is recomputed **from the published list**, never
|
||||
* stored, so hiding/publishing a review re-derives the average (the server's job, mirrored here).
|
||||
* - **Eligibility** reads a single booking from the shared **f8 bookings store** (`mockGetBookingForReview`)
|
||||
* — a booking is reviewable only when `completed`/`closed` AND not already reviewed (the 1:1 rule).
|
||||
* - **Submission tracking** — `createReview` records the customer's review as `pending_moderation` (it does
|
||||
* **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking`
|
||||
* returns the persistent "under review" state.
|
||||
* - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in for the deferred (f15) admin
|
||||
* moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse
|
||||
* profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen.
|
||||
*
|
||||
* Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a
|
||||
* completed booking deep-links correctly.
|
||||
*/
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
/** A submitted review the mock is tracking (client-side stand-in for the missing my-review read, REQ-026). */
|
||||
interface SubmittedReview {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
nurseProfileId: number;
|
||||
rating: number;
|
||||
body: string | null;
|
||||
tagCodes: string[];
|
||||
status: ModerationStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** ISO instant `days` in the past — seeded review timestamps (rendered Shamsi client-side). */
|
||||
function isoDaysAgo(days: number): string {
|
||||
return new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
let nextReviewId = 9100;
|
||||
|
||||
// ── Published reviews per nurse (the public profile tab reads these) ──────────────────────────────────────
|
||||
const PUBLISHED: Record<number, ReviewListItem[]> = {
|
||||
1: [
|
||||
{ id: 9001, rating: 5, body: 'بسیار دقیق و مهربان بود؛ سر وقت رسید و همهچیز را توضیح داد.', tagCodes: ['punctual', 'kind', 'professional'], createdAt: isoDaysAgo(3) },
|
||||
{ id: 9002, rating: 5, body: 'مراقبت حرفهای و تمیز. خیالمان راحت بود.', tagCodes: ['professional', 'clean'], createdAt: isoDaysAgo(9) },
|
||||
{ id: 9003, rating: 4, body: 'ارتباط خوبی با بیمار برقرار کرد.', tagCodes: ['communicative', 'kind'], createdAt: isoDaysAgo(14) },
|
||||
{ id: 9004, rating: 5, body: 'واقعاً منظم و قابلاعتماد.', tagCodes: ['punctual', 'professional'], createdAt: isoDaysAgo(21) },
|
||||
{ id: 9005, rating: 4, body: null, tagCodes: ['clean'], createdAt: isoDaysAgo(28) },
|
||||
{ id: 9006, rating: 5, body: 'از پرستاریاش بسیار راضی بودیم.', tagCodes: ['kind', 'communicative'], createdAt: isoDaysAgo(35) },
|
||||
{ id: 9007, rating: 3, body: 'خوب بود ولی کمی دیر رسید.', tagCodes: ['professional'], createdAt: isoDaysAgo(44) },
|
||||
],
|
||||
2: [
|
||||
{ id: 9021, rating: 5, body: 'با حوصله و مسلط.', tagCodes: ['professional', 'kind'], createdAt: isoDaysAgo(6) },
|
||||
{ id: 9022, rating: 4, body: 'مراقبت خوبی داشت.', tagCodes: ['communicative'], createdAt: isoDaysAgo(18) },
|
||||
],
|
||||
3: [{ id: 9031, rating: 5, body: 'عالی بود، پیشنهاد میکنم.', tagCodes: ['punctual', 'clean', 'kind'], createdAt: isoDaysAgo(11) }],
|
||||
4: [
|
||||
{ id: 9041, rating: 4, body: 'قابل اعتماد و آرام.', tagCodes: ['kind'], createdAt: isoDaysAgo(4) },
|
||||
{ id: 9042, rating: 5, body: 'خیلی حرفهای برخورد کرد.', tagCodes: ['professional', 'communicative'], createdAt: isoDaysAgo(20) },
|
||||
],
|
||||
// nurses 5 & 6: no published reviews → the empty state on their profile tab.
|
||||
};
|
||||
|
||||
const submissions = new Map<number, SubmittedReview>();
|
||||
|
||||
/** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */
|
||||
function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } {
|
||||
const list = PUBLISHED[nurseProfileId] ?? [];
|
||||
if (list.length === 0) return { averageRating: 0, publishedCount: 0 };
|
||||
const sum = list.reduce((acc, r) => acc + r.rating, 0);
|
||||
return { averageRating: Math.round((sum / list.length) * 100) / 100, publishedCount: list.length };
|
||||
}
|
||||
|
||||
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.max(1, params.pageSize ?? all.length);
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||
}
|
||||
|
||||
/** Is this booking (from the shared f8 store) in a review-eligible terminal state? */
|
||||
function isReviewableStatus(status: string): boolean {
|
||||
return status === 'completed' || status === 'closed';
|
||||
}
|
||||
|
||||
export const reviewsMockApi: ReviewsApi = {
|
||||
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// Newest-first, published only — the mock never returns a submission (it is pending_moderation).
|
||||
const list = [...(PUBLISHED[nurseProfileId] ?? [])].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
return { aggregate: aggregateFor(nurseProfileId), reviews: paginate(list, params) };
|
||||
},
|
||||
|
||||
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (submissions.has(bookingId)) return { canReview: false, reason: 'already_reviewed' };
|
||||
let booking;
|
||||
try {
|
||||
booking = mockGetBookingForReview(bookingId);
|
||||
} catch {
|
||||
return { canReview: false, reason: 'not_found' };
|
||||
}
|
||||
if (!isReviewableStatus(booking.status)) return { canReview: false, reason: 'not_completed' };
|
||||
return { canReview: true };
|
||||
},
|
||||
|
||||
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const sub = submissions.get(bookingId);
|
||||
if (!sub) return { status: 'none', rating: null, body: null, tagCodes: [], createdAt: null };
|
||||
return { status: sub.status, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt };
|
||||
},
|
||||
|
||||
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) {
|
||||
throw new ApiError(400, 'Rating must be 1–5', 'rating_out_of_range');
|
||||
}
|
||||
// 1:1 — a second review for the same booking is a 409 (contract).
|
||||
if (submissions.has(bookingId)) throw new ApiError(409, 'Booking already reviewed', 'already_reviewed');
|
||||
|
||||
// Reviews are only for completed/closed bookings — read the shared f8 store (404 → not found).
|
||||
const booking = mockGetBookingForReview(bookingId);
|
||||
if (!isReviewableStatus(booking.status)) {
|
||||
throw new ApiError(409, 'Booking is not completed', 'booking_not_completed');
|
||||
}
|
||||
|
||||
const id = nextReviewId++;
|
||||
const submission: SubmittedReview = {
|
||||
id,
|
||||
bookingId,
|
||||
nurseProfileId: booking.nurseId,
|
||||
rating: body.rating,
|
||||
body: body.body?.trim() || null,
|
||||
tagCodes: body.tagCodes ?? [],
|
||||
// The AI pre-screen keeps clean text pending; it is NOT public until an admin publishes it (f15).
|
||||
status: 'pending_moderation',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
submissions.set(bookingId, submission);
|
||||
|
||||
return {
|
||||
id,
|
||||
moderationStatus: 'pending_moderation',
|
||||
lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* DEV-ONLY: publish a submitted review, standing in for the deferred (f15) admin moderation queue. Moves the
|
||||
* customer's `pending_moderation` submission to `published` and prepends it to the nurse's public list so a
|
||||
* human can watch it appear on the profile (aggregate + count updating on the next fetch). Not wired into any
|
||||
* customer/nurse screen — call it from the console (or a later admin surface). No-op if unknown.
|
||||
*/
|
||||
export function __mockPublishSubmittedReview(bookingId: number): void {
|
||||
const sub = submissions.get(bookingId);
|
||||
if (!sub) return;
|
||||
sub.status = 'published';
|
||||
const list = PUBLISHED[sub.nurseProfileId] ?? (PUBLISHED[sub.nurseProfileId] = []);
|
||||
list.unshift({ id: sub.id, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* When true, the reviews domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `ReviewsApi`
|
||||
* seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b14 serves the review **submit**, the public **nurse reviews** page, and
|
||||
* the tag rollup — but there is **no** review-eligibility read and **no** my-review-for-booking read
|
||||
* (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is admin-only (f15).
|
||||
* The mock reads the shared **f8 bookings store** to gate eligibility on a completed booking, tracks the
|
||||
* customer's submission so the "under review" state persists, seeds a small published-review list per nurse
|
||||
* for the profile tab, and exposes a dev-only `__mockPublishSubmittedReview` so a human can watch a submitted
|
||||
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
|
||||
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
|
||||
*/
|
||||
export const USE_REVIEWS_MOCK = true;
|
||||
|
||||
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
|
||||
export const REVIEWS_PAGE_SIZE = 5;
|
||||
|
||||
/**
|
||||
* The published-review list moves slowly (a moderation transition is a rare admin action) — a generous
|
||||
* `staleTime` means revisiting a profile never needlessly refetches. Eligibility/my-review are per-booking
|
||||
* and are **invalidated on submit**, so their staleness is short (a submit must flip the CTA immediately).
|
||||
*/
|
||||
export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000;
|
||||
export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000;
|
||||
export const REVIEWS_GC_TIME = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The low-rating support-alert threshold (server config, default ≤ 2). The mock echoes it on
|
||||
* `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** — it exists only so the mock's
|
||||
* result shape matches the wire.
|
||||
*/
|
||||
export const MIN_RATING_FOR_SUPPORT_ALERT = 2;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import type { CreateReviewRequest, SubmitReviewResult } from '../types';
|
||||
|
||||
interface CreateReviewVars {
|
||||
bookingId: number;
|
||||
body: CreateReviewRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the one review for a completed booking. On success we invalidate **only** the booking's
|
||||
* `eligibility` + `myReviewForBooking` keys — so the CTA flips to the "under review" state at once — and
|
||||
* deliberately do **NOT** touch any public nurse-reviews list or aggregate: the new review is
|
||||
* `pending_moderation` and must never appear publicly until an admin publishes it. Domain 4xx (`409`
|
||||
* already-reviewed, `400` bad rating) surface to the caller's `onError`, which keeps the draft.
|
||||
*/
|
||||
export function useCreateReview() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<SubmitReviewResult, unknown, CreateReviewVars>({
|
||||
mutationFn: ({ bookingId, body }) => reviewsApi.createReview(bookingId, body),
|
||||
onSuccess: (_result, { bookingId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: reviewKeys.eligibility(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: reviewKeys.myReviewForBooking(bookingId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The customer's **own** review for this booking + its current moderation state (`none` when not yet
|
||||
* reviewed). Drives the persistent "under review" / "published" state on the leave-a-review CTA so a returning
|
||||
* customer never sees a second form. Keyed per booking; **invalidated on submit**.
|
||||
*/
|
||||
export function useMyReviewForBooking(bookingId: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: reviewKeys.myReviewForBooking(bookingId),
|
||||
queryFn: () => reviewsApi.getMyReviewForBooking(bookingId),
|
||||
enabled: (options?.enabled ?? true) && bookingId > 0,
|
||||
staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
|
||||
gcTime: REVIEWS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import { NURSE_REVIEWS_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's **public** reviews list — the rating aggregate + an infinite, load-more list of **published**
|
||||
* reviews. Infinite paging keeps every loaded page in one cache entry keyed on the nurse id, so revisiting a
|
||||
* profile never refetches and "load more" appends without a setState-in-effect. The aggregate rides on every
|
||||
* page (same value) — read it off the first page. Never returns/renders non-published content (server
|
||||
* invariant); a submitted `pending_moderation` review is **never** injected here.
|
||||
*/
|
||||
export function useNurseReviews(nurseProfileId: number | undefined) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: reviewKeys.nurse(nurseProfileId ?? -1),
|
||||
queryFn: ({ pageParam }) =>
|
||||
reviewsApi.getNurseReviews(nurseProfileId as number, { page: pageParam, pageSize: REVIEWS_PAGE_SIZE }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.reviews;
|
||||
return page * pageSize < total ? page + 1 : undefined;
|
||||
},
|
||||
enabled: nurseProfileId != null,
|
||||
staleTime: NURSE_REVIEWS_STALE_TIME,
|
||||
gcTime: REVIEWS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* Whether *this* booking can still be reviewed (completed/closed AND not already reviewed). Keyed per booking;
|
||||
* short `staleTime` and **invalidated on submit** so the leave-a-review CTA flips to the "under review" state
|
||||
* immediately. `enabled` lets the caller defer until it holds a real booking id.
|
||||
*/
|
||||
export function useReviewEligibility(bookingId: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: reviewKeys.eligibility(bookingId),
|
||||
queryFn: () => reviewsApi.getReviewEligibility(bookingId),
|
||||
enabled: (options?.enabled ?? true) && bookingId > 0,
|
||||
staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
|
||||
gcTime: REVIEWS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Reviews domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
|
||||
* types/keys/apis directly from their files when needed.
|
||||
*/
|
||||
export { useNurseReviews } from './hooks/useNurseReviews';
|
||||
export { useReviewEligibility } from './hooks/useReviewEligibility';
|
||||
export { useMyReviewForBooking } from './hooks/useMyReviewForBooking';
|
||||
export { useCreateReview } from './hooks/useCreateReview';
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern).
|
||||
*
|
||||
* The **nurse id + page** key the public list so paging/revisiting a profile never refetches data already in
|
||||
* cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state
|
||||
* independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the
|
||||
* booking — the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public).
|
||||
*/
|
||||
export const reviewKeys = {
|
||||
all: ['reviews'] as const,
|
||||
|
||||
nurseLists: () => [...reviewKeys.all, 'nurse'] as const,
|
||||
/** The public reviews list for a nurse. Pages are managed by `useInfiniteQuery`, so no page in the key. */
|
||||
nurse: (nurseProfileId: number) => [...reviewKeys.nurseLists(), nurseProfileId] as const,
|
||||
|
||||
eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const,
|
||||
myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Reviews domain — the **moderated trust signal** (b14). A customer leaves **one review per completed
|
||||
* booking**; it is born `pending_moderation` and is **never public / never counted** until an admin (or the
|
||||
* AI pre-screen) publishes it; the public read only ever returns `published` reviews + the recomputed
|
||||
* aggregate. Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` +
|
||||
* `dev/contracts/openapi/swagger.v1.json`), mirroring the wire exactly.
|
||||
*
|
||||
* **What the contract serves (real):** submit a review (`POST bookings/{id}/review`), the public nurse
|
||||
* reviews page (`GET nurses/{id}/reviews` — aggregate + published page), the per-nurse tag rollup, and the
|
||||
* admin moderation transition (admin-only → f15). **What it does NOT serve (client gaps, REQ-026):** a
|
||||
* **review-eligibility** read (whether *this* booking can still be reviewed) and a **my-review-for-booking**
|
||||
* read (the customer's own submitted review + its moderation state, so the CTA can show the persistent
|
||||
* "under review" state). Those two are derived/mocked behind this seam and the domain is **mock-primary**
|
||||
* (see `constants.ts`); when REQ-026 lands only `apis/clientApi.ts`'s two gap methods flip.
|
||||
*
|
||||
* Load-bearing rules (contract + phase §5):
|
||||
* - **Never render `pending_moderation`/`hidden`/`rejected` publicly.** `getNurseReviews` returns published
|
||||
* only (server-filtered); after a submit we show a **local** "under review" state and **never**
|
||||
* optimistically inject the new review into any public list or aggregate.
|
||||
* - **1:1** — one review per booking; a second submit is a `409`.
|
||||
* - Review-eligible = the booking is **completed/closed** AND not already reviewed.
|
||||
* - Tag chip labels are **i18n keys keyed off the code**, never a label off the wire.
|
||||
*
|
||||
* Enums cross the wire as stable string codes — mirrored here as string-literal unions.
|
||||
*/
|
||||
|
||||
/** `moderationStatus` (contract enum). Born `pending_moderation`; only `published` is ever public/counted. */
|
||||
export type ModerationStatus = 'pending_moderation' | 'published' | 'hidden' | 'rejected';
|
||||
|
||||
/**
|
||||
* The seeded review-tag vocabulary (contract "Review tag codes"). The chip **label** is the client's i18n
|
||||
* key (`reviews.tag_{code}`), never a label off the wire.
|
||||
*/
|
||||
export const REVIEW_TAG_CODES = ['punctual', 'professional', 'clean', 'kind', 'communicative'] as const;
|
||||
export type ReviewTagCode = (typeof REVIEW_TAG_CODES)[number];
|
||||
|
||||
/** A public review row (`ReviewListItemDto`) — **never** carries moderation internals or a customer name. */
|
||||
export interface ReviewListItem {
|
||||
id: number;
|
||||
/** 1–5. */
|
||||
rating: number;
|
||||
body: string | null;
|
||||
tagCodes: string[];
|
||||
/** UTC ISO-8601 — Shamsi display is the client's job. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The nurse rating aggregate (`NurseReviewAggregateDto`) — computed **from published reviews only**, server-side. */
|
||||
export interface NurseReviewAggregate {
|
||||
/** 2-dp decimal (e.g. `4.5`). */
|
||||
averageRating: number;
|
||||
publishedCount: number;
|
||||
}
|
||||
|
||||
/** `NurseReviewsResult` — the public reviews page for a nurse: the aggregate + a page of published reviews. */
|
||||
export interface NurseReviews {
|
||||
aggregate: NurseReviewAggregate;
|
||||
reviews: Paginated<ReviewListItem>;
|
||||
}
|
||||
|
||||
/** The submit body (`SubmitReviewBody`; the booking id comes from the route). */
|
||||
export interface CreateReviewRequest {
|
||||
/** 1–5, required. */
|
||||
rating: number;
|
||||
/** ≤ 2000, optional. */
|
||||
body?: string | null;
|
||||
/** Validated against the active vocabulary; optional. */
|
||||
tagCodes?: string[];
|
||||
}
|
||||
|
||||
/** `SubmitReviewResult`. `moderationStatus` is `pending_moderation` by default (clean text stays pending). */
|
||||
export interface SubmitReviewResult {
|
||||
id: number;
|
||||
moderationStatus: ModerationStatus;
|
||||
/** Internal (rating ≤ threshold raised a support alert) — never surfaced to the user; we ignore it in the UI. */
|
||||
lowRatingAlertRaised: boolean;
|
||||
}
|
||||
|
||||
/** Why a booking cannot be reviewed (client-derived; drives the not-eligible copy). */
|
||||
export type ReviewIneligibilityReason = 'not_completed' | 'already_reviewed' | 'not_owner' | 'not_found';
|
||||
|
||||
/**
|
||||
* Whether *this* booking can still be reviewed (**REQ-026 — no wire endpoint**). Derived from the booking
|
||||
* status (completed/closed) + the 1:1 rule. Mocked behind the seam; the real client targets a proposed slug.
|
||||
*/
|
||||
export interface ReviewEligibility {
|
||||
canReview: boolean;
|
||||
reason?: ReviewIneligibilityReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's **own** review for a booking + its current moderation state (**REQ-026 — no wire
|
||||
* endpoint**). `status: 'none'` = not yet reviewed. Lets the CTA render the persistent "under review" /
|
||||
* "published" state across sessions without leaking anything public.
|
||||
*/
|
||||
export interface MyReviewState {
|
||||
status: ModerationStatus | 'none';
|
||||
rating: number | null;
|
||||
body: string | null;
|
||||
tagCodes: string[];
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviews API seam — the real HTTP client and the in-memory mock both implement this; selection is by
|
||||
* config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface ReviewsApi {
|
||||
/** Public — published reviews + aggregate for a nurse (server filters to published). */
|
||||
getNurseReviews(nurseProfileId: number, params: PageParams): Promise<NurseReviews>;
|
||||
/** REQ-026 — can this booking still be reviewed? */
|
||||
getReviewEligibility(bookingId: number): Promise<ReviewEligibility>;
|
||||
/** REQ-026 — the customer's own review for this booking + its moderation state. */
|
||||
getMyReviewForBooking(bookingId: number): Promise<MyReviewState>;
|
||||
/** Submit the one review for a completed booking (`409` if already reviewed). */
|
||||
createReview(bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user