Files
clinicpro/assets/admin/pages/MyPatientsPage.tsx
T

555 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon,
FolderOpenIcon, UsersIcon,
} 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 { 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';
import FeatureGate from '../components/ui/FeatureGate';
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 PAYMENT_BADGE: Record<string, string> = {
cash: 'green', card: 'blue', insurance: 'purple', online: 'blue', pending: 'amber',
};
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;
}
function MyPatientsPageInner() {
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: recordsData, isLoading } = useQuery<PaginatedResponse<PatientRecord>>({
queryKey: ['patients', page, search],
queryFn: () => api.get(`/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`),
});
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 { 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: 'user',
header: 'بیمار',
render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
className="avatar sm"
style={{
background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))',
flexShrink: 0,
fontWeight: 700,
}}
>
{(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, marginTop: 2 }}>
<PhoneIcon style={{ width: 11 }} />
<span dir="ltr">{r.user.phone ?? '—'}</span>
</div>
</div>
</div>
),
},
{
key: 'created_at',
header: 'تاریخ ثبت',
render: (r) => (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(r.created_at)}</span>
),
},
{
key: 'uuid',
header: '',
render: (r) => (
<button
className="btn primary sm"
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
onClick={() => { setSelectedRecord(r); setSessionPage(1); }}
>
<FolderOpenIcon style={{ width: 14 }} />
مشاهده پرونده
</button>
),
},
];
if (!selectedRecord) {
return (
<>
<PageHeader title="پرونده بیماران" description="مراجعه‌کنندگان ثبت‌شده شما" />
<div style={{ marginBottom: 16 }}>
<div style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
<input
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..."
/>
</div>
</div>
<div className="card">
{records.length === 0 && !isLoading ? (
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
<UsersIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
{search ? 'بیماری یافت نشد' : 'هنوز بیماری ثبت نشده است'}
</div>
<div style={{ fontSize: 13 }}>
{search ? 'عبارت جستجو را تغییر دهید' : 'پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود'}
</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>
</>
);
}
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>
}
/>
{/* بنر اطلاعات بیمار */}
<div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
borderRadius: 'var(--r)',
padding: '16px 20px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
color: '#fff',
}}>
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 700, flexShrink: 0,
}}>
{(selectedRecord.user.fullName ?? '?').charAt(0)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 17 }}>{selectedRecord.user.fullName ?? '—'}</div>
<div style={{ fontSize: 13, opacity: 0.85, marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
<PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{selectedRecord.user.phone ?? '—'}</span>
</div>
</div>
<div style={{ textAlign: 'center', background: 'rgba(255,255,255,0.15)', borderRadius: 10, padding: '8px 16px' }}>
<div style={{ fontSize: 22, fontWeight: 800 }}>{totalSes}</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
</div>
</div>
<div className="card">
<div style={{ fontWeight: 600, padding: '14px 16px 10px', borderBottom: '1px solid var(--border)', fontSize: 14 }}>
تاریخچه مراجعات
</div>
{sessionsLoading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<FolderOpenIcon style={{ width: 40, margin: '0 auto 12px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-2)' }}>مراجعه‌ای ثبت نشده است</div>
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}
>
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
</button>
</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: 14 }}>
<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: 10 }}>افزودن خدمات</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<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 primary sm" onClick={handleAddService} disabled={!itemUuid}>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{selectedServices.map((svc) => (
<div
key={svc.service_item_uuid}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'var(--primary-subtle)', border: '1px solid oklch(0.85 0.06 256)',
borderRadius: 20, padding: '4px 10px', fontSize: 12.5,
}}
>
<span style={{ fontWeight: 500 }}>{svc.name}</span>
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>{formatRial(svc.price_rials)}</span>
<button
type="button"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, fontSize: 14, lineHeight: 1 }}
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}
>×</button>
</div>
))}
</div>
)}
</div>
<div style={{ background: 'var(--primary-subtle)', border: '1px solid oklch(0.88 0.05 256)', borderRadius: 10, padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6, fontSize: 13, color: 'var(--text-3)' }}>
<span>جمع خدمات</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);
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? 'gray';
return (
<div style={{
borderBottom: '1px solid var(--border)',
transition: 'background 0.1s',
}}>
<div
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 16px', cursor: 'pointer',
}}
onClick={() => setOpen(!open)}
>
<div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: open ? 'var(--primary)' : 'var(--border)',
transition: 'background 0.15s', flexShrink: 0,
}} />
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDateTime(session.created_at)}</span>
<span className={`badge ${badgeColor}`}>
{PAYMENT_LABELS[session.payment_method] ?? session.payment_method}
</span>
<b style={{ fontSize: 14, color: 'var(--primary)' }}>{formatRial(session.final_price_rials)}</b>
</div>
<button
className="btn sm"
onClick={(e) => { e.stopPropagation(); onEdit(session); }}
title="ویرایش مراجعه"
>
<PencilIcon style={{ width: 14 }} />
</button>
</div>
{open && (
<div style={{ padding: '0 16px 14px' }}>
<div style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 10,
padding: 14,
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13,
}}>
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>قیمت ویزیت</div>
<div style={{ fontWeight: 600 }}>{formatRial(session.visit_price_rials)}</div>
</div>
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>تخفیف بیمه پایه</div>
<div style={{ fontWeight: 600 }}>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div>
</div>
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>جمع خدمات</div>
<div style={{ fontWeight: 600 }}>{formatRial(session.services_total_rials)}</div>
</div>
{session.notes && (
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 2 }}>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>یادداشت</div>
<div style={{ lineHeight: 1.6 }}>{session.notes}</div>
</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>
);
}
export default function MyPatientsPage() {
return (
<FeatureGate feature="patient_records">
<MyPatientsPageInner />
</FeatureGate>
);
}