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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user