feat: rewrite MyPatientsPage with patient records and sessions (TASK-15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-14 22:27:15 +03:30
co-authored by Claude Sonnet 4.6
parent ed18c260ca
commit 69df9947a9
+368 -53
View File
@@ -1,86 +1,401 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { PhoneIcon } from '@heroicons/react/24/outline';
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon,
} from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber } from '../lib/utils';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { PatientRecord, PatientSession, ServiceSection, ServiceItem } from '../types';
import { formatDate, formatDateTime, formatRial, formatNumber } from '../lib/utils';
import DataTable, { type Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
interface Patient {
uuid: string;
name: string;
mobile: string;
total_appointments: number;
last_appointment: number | null;
const sessionSchema = z.object({
visit_price_rials: z.coerce.number().min(0),
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
supplementary_discount_percent: z.coerce.number().min(0).max(100),
payment_method: z.enum(['cash', 'card', 'insurance', 'online', 'pending']),
notes: z.string().optional(),
});
type SessionFormData = z.infer<typeof sessionSchema>;
const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
};
const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = [];
function calcFinalPrice(visitPrice: number, baseDiscount: number, suppDiscount: number, servicesTotal: number) {
const afterBase = visitPrice * (1 - baseDiscount / 100);
const afterSupp = afterBase * (1 - suppDiscount / 100);
return Math.round(afterSupp) + servicesTotal;
}
const EMPTY: Patient[] = [];
export default function MyPatientsPage() {
const qc = useQueryClient();
const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [sessionPage, setSessionPage] = useState(1);
const [sessionModal, setSessionModal] = useState(false);
const [editSession, setEditSession] = useState<PatientSession | null>(null);
const form = useForm<SessionFormData>({
resolver: zodResolver(sessionSchema),
defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' },
});
const watchVisit = form.watch('visit_price_rials') ?? 0;
const watchBase = form.watch('base_insurance_discount_percent') ?? 0;
const watchSupp = form.watch('supplementary_discount_percent') ?? 0;
const [selectedServices, setSelectedServices] = useState<{ service_item_uuid: string; name: string; price_rials: number }[]>([]);
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const servicesTotal = selectedServices.reduce((sum, s) => sum + s.price_rials, 0);
const finalPrice = calcFinalPrice(Number(watchVisit), Number(watchBase), Number(watchSupp), servicesTotal);
const limit = 20;
const { data, isLoading } = useQuery<PaginatedResponse<Patient>>({
queryKey: ['my-patients', page, search],
queryFn: () => api.get(`/api/v1/my/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`),
const { data: recordsData, isLoading } = useQuery<PaginatedResponse<PatientRecord>>({
queryKey: ['patients', page, search],
queryFn: () => api.get(`/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`),
});
const patients = data?.data ?? EMPTY;
const total = data?.meta?.totalRecords ?? 0;
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<PaginatedResponse<PatientSession>>({
queryKey: ['patient-sessions', selectedRecord?.uuid, sessionPage],
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`),
enabled: !!selectedRecord,
});
const columns: Column<Patient>[] = [
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'],
queryFn: () => api.get('/api/v1/service-sections'),
});
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
queryKey: ['service-items-for-session', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const records = recordsData?.data ?? EMPTY_RECORDS;
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
const totalRec = recordsData?.meta?.totalRecords ?? 0;
const totalSes = sessionsData?.meta?.totalRecords ?? 0;
const sectionOptions = (sectionsData?.data ?? []).map((s) => ({ value: s.uuid, label: s.name }));
const itemOptions = (itemsData?.data ?? []).filter((i) => i.active).map((i) => ({
value: i.uuid,
label: `${i.name}${formatRial(i.price_rials)}`,
}));
const createSessionMut = useMutation({
mutationFn: (body: object) => api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', selectedRecord?.uuid] });
setSessionModal(false);
form.reset();
setSelectedServices([]);
toast.success('مراجعه ثبت شد');
},
onError: (e: any) => toast.error(e.message),
});
const updateSessionMut = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: object }) => api.patch(`/api/v1/session/${uuid}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', selectedRecord?.uuid] });
setEditSession(null);
toast.success('مراجعه ویرایش شد');
},
onError: (e: any) => toast.error(e.message),
});
const handleAddService = () => {
if (!itemUuid) return;
const found = itemsData?.data?.find((i) => i.uuid === itemUuid);
if (!found || selectedServices.some((s) => s.service_item_uuid === found.uuid)) return;
setSelectedServices((p) => [...p, { service_item_uuid: found.uuid, name: found.name, price_rials: found.price_rials }]);
setItemUuid('');
};
const handleSubmitSession = form.handleSubmit((d) => {
createSessionMut.mutate({ ...d, services: selectedServices.map((s) => ({ service_item_uuid: s.service_item_uuid })) });
});
const handleSearch = useCallback((v: string) => { setSearch(v); setPage(1); }, []);
const recordColumns: Column<PatientRecord>[] = [
{
key: 'name',
header: 'نام بیمار',
render: (p) => (
key: 'user',
header: 'بیمار',
render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="avatar sm" style={{ background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))', flexShrink: 0 }}>
{(p.name || '؟').charAt(0)}
{(r.user.fullName ?? '?').charAt(0)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.user.fullName ?? '—'}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }}>
<PhoneIcon style={{ width: 11 }} />
<span dir="ltr">{r.user.phone ?? '—'}</span>
</div>
</div>
<span>{p.name || '—'}</span>
</div>
),
},
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
{
key: 'mobile',
header: 'موبایل',
render: (p) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, direction: 'ltr' }}>
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)' }} />
<span>{p.mobile}</span>
</div>
key: 'uuid',
header: 'عملیات',
render: (r) => (
<button className="btn primary sm" onClick={() => { setSelectedRecord(r); setSessionPage(1); }}>
پرونده
</button>
),
},
{
key: 'total_appointments',
header: 'تعداد نوبت',
render: (p) => <span className="badge blue">{formatNumber(p.total_appointments)}</span>,
},
{
key: 'last_appointment',
header: 'آخرین نوبت',
render: (p) => p.last_appointment ? formatDate(String(p.last_appointment)) : '—',
},
];
return (
<div className="page">
<PageHeader title="بیماران من" description={`${formatNumber(total)} بیمار`} />
if (!selectedRecord) {
return (
<>
<PageHeader title="پرونده بیماران" description="لیست بیماران و سوابق مراجعات" />
<div className="card">
<div style={{ padding: '12px 16px 0', display: 'flex', gap: 8 }}>
<div style={{ position: 'relative', flex: 1 }}>
<MagnifyingGlassIcon style={{ width: 16, position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
<input
style={{ paddingRight: 32, width: '100%' }}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..."
/>
</div>
</div>
<DataTable columns={recordColumns} data={records} loading={isLoading} emptyMessage="بیماری ثبت نشده است" />
<div style={{ padding: '0 16px 12px' }}>
<Pagination page={page} total={totalRec} limit={limit} onPageChange={setPage} />
</div>
</div>
</>
);
}
<DataTable
columns={columns}
data={patients}
loading={isLoading}
searchValue={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
searchPlaceholder="جستجوی نام یا موبایل..."
emptyMessage="بیماری یافت نشد"
return (
<>
<PageHeader
title={selectedRecord.user.fullName ?? 'بیمار'}
description={`تلفن: ${selectedRecord.user.phone ?? '—'} — پرونده از ${formatDate(selectedRecord.created_at)}`}
action={
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn sm" onClick={() => setSelectedRecord(null)}>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<button className="btn primary sm" onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
</div>
}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
<div className="card">
<div style={{ fontWeight: 600, padding: '12px 16px 8px' }}>مراجعات</div>
{sessionsLoading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>مراجعهای ثبت نشده است</div>
) : (
sessions.map((s) => <SessionRow key={s.uuid} session={s} onEdit={setEditSession} />)
)}
{totalSes > 20 && (
<div style={{ padding: '0 16px 12px' }}>
<Pagination page={sessionPage} total={totalSes} limit={20} onPageChange={setSessionPage} />
</div>
)}
</div>
{/* Modal مراجعه جدید */}
<Modal open={sessionModal} onClose={() => setSessionModal(false)} title="ثبت مراجعه جدید">
<form onSubmit={handleSubmitSession}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div className="field">
<label>قیمت ویزیت (ریال)</label>
<input {...form.register('visit_price_rials')} type="number" min={0} dir="ltr" />
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input {...form.register('base_insurance_discount_percent')} type="number" min={0} max={100} dir="ltr" />
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input {...form.register('supplementary_discount_percent')} type="number" min={0} max={100} dir="ltr" />
</div>
</div>
<div className="field">
<label>روش پرداخت</label>
<select {...form.register('payment_method')}>
{Object.entries(PAYMENT_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div className="field">
<label>یادداشت</label>
<textarea {...form.register('notes')} rows={2} placeholder="یادداشت پزشک..." />
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>افزودن سرویس</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<SearchableSelect
options={sectionOptions}
value={sectionUuid}
onChange={(v) => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
placeholder="انتخاب بخش..."
/>
</div>
<div style={{ flex: 1 }}>
<SearchableSelect
options={itemOptions}
value={itemUuid}
onChange={(v) => setItemUuid(v ? String(v) : '')}
placeholder="انتخاب سرویس..."
isDisabled={!sectionUuid}
/>
</div>
<button type="button" className="btn sm" onClick={handleAddService} disabled={!itemUuid}>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.map((svc) => (
<div key={svc.service_item_uuid} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '4px 8px', background: 'var(--surface-2)', borderRadius: 4, marginBottom: 4 }}>
<span>{svc.name}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span>{formatRial(svc.price_rials)}</span>
<button type="button" style={{ fontSize: 11, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer' }}
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}>
حذف
</button>
</div>
</div>
))}
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 8, padding: 12, fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: 'var(--text-3)' }}>جمع سرویسها</span>
<span>{formatRial(servicesTotal)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 15 }}>
<span>مبلغ نهایی</span>
<span style={{ color: 'var(--primary)' }}>{formatRial(finalPrice)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn primary" disabled={createSessionMut.isPending}>
{createSessionMut.isPending ? 'در حال ذخیره...' : 'ثبت مراجعه'}
</button>
<button type="button" className="btn" onClick={() => setSessionModal(false)}>انصراف</button>
</div>
</div>
</form>
</Modal>
<EditSessionModal
session={editSession}
onClose={() => setEditSession(null)}
onSave={(uuid, body) => updateSessionMut.mutate({ uuid, body })}
loading={updateSessionMut.isPending}
/>
</>
);
}
function SessionRow({ session, onEdit }: { session: PatientSession; onEdit: (s: PatientSession) => void }) {
const [open, setOpen] = useState(false);
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', cursor: 'pointer' }}
onClick={() => setOpen(!open)}
>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDateTime(session.created_at)}</span>
<span className={`badge ${session.payment_method === 'pending' ? 'amber' : 'green'}`}>
{PAYMENT_LABELS[session.payment_method] ?? session.payment_method}
</span>
<b style={{ fontSize: 14 }}>{formatRial(session.final_price_rials)}</b>
</div>
<button className="btn sm" onClick={(e) => { e.stopPropagation(); onEdit(session); }}>
<PencilIcon style={{ width: 14 }} />
</button>
</div>
{open && (
<div style={{ padding: '0 16px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13 }}>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>قیمت ویزیت</div><div>{formatRial(session.visit_price_rials)}</div></div>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>تخفیف بیمه پایه</div><div>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div></div>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>جمع سرویسها</div><div>{formatRial(session.services_total_rials)}</div></div>
{session.notes && (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ color: 'var(--text-3)', marginBottom: 2 }}>یادداشت</div>
<div>{session.notes}</div>
</div>
)}
</div>
)}
</div>
);
}
function EditSessionModal({
session, onClose, onSave, loading,
}: {
session: PatientSession | null;
onClose: () => void;
onSave: (uuid: string, body: object) => void;
loading: boolean;
}) {
const [notes, setNotes] = useState('');
const [method, setMethod] = useState('cash');
React.useEffect(() => {
if (session) { setNotes(session.notes ?? ''); setMethod(session.payment_method); }
}, [session]);
return (
<Modal open={!!session} onClose={onClose} title="ویرایش مراجعه">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
<label>روش پرداخت</label>
<select value={method} onChange={(e) => setMethod(e.target.value)}>
{Object.entries(PAYMENT_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div className="field">
<label>یادداشت</label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={loading} onClick={() => session && onSave(session.uuid, { notes, payment_method: method })}>
{loading ? 'در حال ذخیره...' : 'ذخیره'}
</button>
<button className="btn" onClick={onClose}>انصراف</button>
</div>
</div>
</Modal>
);
}