The no-show records existed and drove the risk tag, but the patient's file
never showed the number behind it — the operator saw a tag with no evidence.
GET /patient/{uuid}/no-shows returns the count, the policy threshold and the
window, and the banner shows it only when the count is above zero: "0 no-shows"
on every healthy patient's file is an accusation nobody made.
The badge does not block anything and the docs say so. Blocking is an
eligibility policy from task 09 built on the same tag; a clinic that wants to
see the risk but still take a deposit must not have to switch the count off.
A test pins that a tagged patient still books.
Both report pages kept their range and branch in local state, so going back
from a resource lost the report and a shared link opened someone else's
default. They use useUrlState now, like every other list in the panel.
Three tests that were owed:
- the service-level cancellation policy beats the tenant one with no blending,
checked through the number that comes out rather than through the resolver
- a patient over the no-show threshold can still book
- occupied includes the waiting segment while active does not — if those two
came back equal the whole utilization report would be pointless
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1194 lines
60 KiB
TypeScript
1194 lines
60 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
|
||
import {
|
||
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon,
|
||
ArrowPathRoundedSquareIcon,
|
||
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
|
||
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
||
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||
import { usePackages, usePatientPackages } from '../hooks/usePackages';
|
||
import { useCourseProtocols, usePatientCourses } from '../hooks/useCourses';
|
||
import { usePatientNoShows } from '../hooks/useCancellation';
|
||
import { toast } from 'sonner';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import type { PatientRecord } from '../types';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import { formatDate, formatDateTime, formatRial, formatTime, formatNumber, unixToIso } from '../lib/utils';
|
||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||
import type { WalletTxn } from '../hooks/usePatientWallet';
|
||
import type { WalletModalSubmit } from '../components/WalletTransactionModal';
|
||
import Modal from '../components/ui/Modal';
|
||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||
import { useIssueInvoice } from '../hooks/useIssueInvoice';
|
||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||
import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion';
|
||
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
|
||
import SessionAuditModal from '../components/session/SessionAuditModal';
|
||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
|
||
import {
|
||
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabCall, TabAttach, TabBody,
|
||
} from '../components/icons/FilesServiceIcons';
|
||
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
|
||
import WalletTransactionModal from '../components/WalletTransactionModal';
|
||
import { usePatientWallet } from '../hooks/usePatientWallet';
|
||
import {
|
||
profileToFormValues, formValuesToPayload,
|
||
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
||
} from '../lib/patientForm';
|
||
import { usePermissions } from '../hooks/usePermissions';
|
||
|
||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'courses' | 'notes' | 'callcenter' | 'attach' | 'records';
|
||
|
||
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
|
||
{ key: 'services', label: 'سرویسها', icon: (c) => <TabServices color={c} /> },
|
||
{ key: 'info', label: 'اطلاعات پرونده', icon: (c) => <TabInfo color={c} /> },
|
||
{ key: 'appointments', label: 'نوبتها', icon: (c) => <TabCalendar color={c} /> },
|
||
{ key: 'payments', label: 'پرداختها', icon: (c) => <TabCard color={c} /> },
|
||
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
|
||
{ key: 'packages', label: 'پکیجها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
|
||
{ key: 'courses', label: 'دورههای درمان', icon: (c) => <ArrowPathRoundedSquareIcon style={{ width: 18, color: c }} /> },
|
||
{ key: 'notes', label: 'یادداشتها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
|
||
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
|
||
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
|
||
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
|
||
];
|
||
|
||
function Placeholder({ label }: { label: string }) {
|
||
return (
|
||
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||
محتوای «{label}» بهزودی تکمیل میشود.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
|
||
export default function PatientDetailPage() {
|
||
const { uuid } = useParams<{ uuid: string }>();
|
||
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
// ?tab=wallet etc. lets other pages (e.g. appointment forms) deep-link a tab
|
||
const [searchParams] = useSearchParams();
|
||
const requested = searchParams.get('tab') as TabKey | null;
|
||
const [tab, setTab] = useState<TabKey>(
|
||
requested && TABS.some((t) => t.key === requested) ? requested : 'services',
|
||
);
|
||
|
||
const { data, isLoading } = useQuery<ApiResponse<PatientRecord>>({
|
||
queryKey: ['patient', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}`),
|
||
enabled: !!uuid,
|
||
});
|
||
const record = data?.data;
|
||
|
||
const qc = useQueryClient();
|
||
const nav = useNavigate();
|
||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||
const [auditSessionUuid, setAuditSessionUuid] = useState<string | null>(null);
|
||
const [sessionFilter, setSessionFilter] = useState<'active' | 'all' | 'archived'>('active');
|
||
|
||
// «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر میشود (idempotent).
|
||
const issueInvoiceMut = useIssueInvoice(uuid);
|
||
const viewInvoice = (s: SessionCardData) => {
|
||
if (s.invoice_uuid) { setInvoiceUuid(s.invoice_uuid); return; }
|
||
issueInvoiceMut.mutate(s.uuid, {
|
||
onSuccess: (iv) => setInvoiceUuid(iv),
|
||
onError: () => toast.error('صدور فاکتور ناموفق بود'),
|
||
});
|
||
};
|
||
|
||
const archiveMut = useMutation({
|
||
mutationFn: ({ sessionUuid, archived }: { sessionUuid: string; archived: boolean }) =>
|
||
api.patch(`/api/v1/session/${sessionUuid}`, { archived }),
|
||
onSuccess: (_r, v) => {
|
||
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
|
||
toast.success(v.archived ? 'مراجعه آرشیو شد' : 'مراجعه از آرشیو خارج شد');
|
||
},
|
||
onError: (e: any) => toast.error(e?.message || 'خطا در آرشیو'),
|
||
});
|
||
|
||
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
||
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
||
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||
const seededProvince = useRef(false);
|
||
useEffect(() => {
|
||
if (!seededProvince.current && record?.profile) {
|
||
setEditProvinceId(record.profile.province_id ?? null);
|
||
seededProvince.current = true;
|
||
}
|
||
}, [record]);
|
||
|
||
const provincesQ = useQuery<any>({
|
||
queryKey: ['provinces'],
|
||
queryFn: () => api.get('/api/v1/provinces'),
|
||
staleTime: 600_000,
|
||
});
|
||
const citiesQ = useQuery<any>({
|
||
queryKey: ['cities', editProvinceId],
|
||
queryFn: () => api.get(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ''}`),
|
||
staleTime: 300_000,
|
||
});
|
||
const pricingQ = useQuery<ApiResponse<any>>({
|
||
queryKey: ['insurance-pricing'],
|
||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||
enabled: !!uuid,
|
||
});
|
||
const locOpts = (raw: any) =>
|
||
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
|
||
const baseInsuranceOptions = ((pricingQ.data?.data as any)?.insurances ?? [])
|
||
.filter((i: any) => i.type === 'basic')
|
||
.map((i: any) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||
const suppInsuranceOptions = ((pricingQ.data?.data as any)?.insurances ?? [])
|
||
.filter((i: any) => i.type === 'supplementary')
|
||
.map((i: any) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||
|
||
const updateProfileMut = useMutation({
|
||
mutationFn: (body: object) => api.patch(`/api/v1/patient/${uuid}`, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['patient', uuid] });
|
||
toast.success('اطلاعات بیمار بهروزرسانی شد');
|
||
},
|
||
onError: (e: any) => toast.error(e?.message || 'خطا در بهروزرسانی اطلاعات بیمار'),
|
||
});
|
||
|
||
// sessions + appointments load eagerly so the banner (debt / next visit) is ready.
|
||
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
|
||
queryKey: ['patient-sessions', uuid, sessionFilter],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${sessionFilter}`),
|
||
enabled: !!uuid,
|
||
});
|
||
const appointmentsQ = useQuery<ApiResponse<any[]>>({
|
||
queryKey: ['patient-appointments', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`),
|
||
enabled: !!uuid,
|
||
});
|
||
const sessions = sessionsQ.data?.data ?? [];
|
||
const hasDebt = sessions.some((s) => !s.is_paid);
|
||
const nowSec = Math.floor(Date.now() / 1000);
|
||
const { noShows } = usePatientNoShows(uuid);
|
||
|
||
const nextAppointment = (appointmentsQ.data?.data ?? [])
|
||
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
|
||
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
|
||
|
||
return (
|
||
<div className="fade-in">
|
||
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
|
||
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
|
||
<PatientCaseBanner
|
||
name={record?.user_name || 'پرونده'}
|
||
recordNumber={record?.record_number}
|
||
mobile={record?.user_mobile}
|
||
createdAt={(record as any)?.created_at}
|
||
tags={(record as any)?.tags}
|
||
nextAppointment={nextAppointment}
|
||
hasDebt={hasDebt}
|
||
noShows={noShows}
|
||
onAddNote={() => setTab('notes')}
|
||
/>
|
||
|
||
{/* tab bar */}
|
||
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
|
||
{TABS.map((t) => {
|
||
const on = t.key === tab;
|
||
return (
|
||
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap',
|
||
padding: '10px 14px', border: 'none', background: 'none', cursor: 'pointer',
|
||
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
|
||
color: on ? 'var(--primary)' : 'var(--text-2)',
|
||
borderBottom: `2px solid ${on ? 'var(--primary)' : 'transparent'}`,
|
||
}}>
|
||
{t.icon(on ? 'var(--primary)' : 'var(--text-2)')} {t.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* tab content */}
|
||
{isLoading ? (
|
||
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : tab === 'info' ? (
|
||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
|
||
<PatientRecordInfoForm
|
||
defaultValues={profileToFormValues(record?.profile ?? null)}
|
||
recordNumber={record?.record_number}
|
||
options={{
|
||
gender: GENDER_OPTS,
|
||
marital: MARITAL_OPTS,
|
||
education: EDUCATION_OPTS,
|
||
referral: REFERRAL_OPTS,
|
||
insurance: baseInsuranceOptions,
|
||
supplementary: suppInsuranceOptions,
|
||
province: locOpts(provincesQ.data),
|
||
city: locOpts(citiesQ.data),
|
||
}}
|
||
onSubmit={(v) => updateProfileMut.mutate(formValuesToPayload(v))}
|
||
isSubmitting={updateProfileMut.isPending}
|
||
onProvinceChange={setEditProvinceId}
|
||
/>
|
||
</div>
|
||
) : tab === 'services' ? (
|
||
<div>
|
||
{/* filter (همه/فعال/آرشیو) + new-service */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<div className="seg">
|
||
<button className={sessionFilter === 'active' ? 'on' : ''} onClick={() => setSessionFilter('active')}>فعالها</button>
|
||
<button className={sessionFilter === 'all' ? 'on' : ''} onClick={() => setSessionFilter('all')}>همه</button>
|
||
<button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button>
|
||
</div>
|
||
{canUpdate && (
|
||
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||
<AddTurn color="var(--on-primary)" /> سرویس جدید
|
||
</Link>
|
||
)}
|
||
</div>
|
||
{sessionsQ.isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : sessions.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>سرویسی ثبت نشده است</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||
{sessions.map((s) => (
|
||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} onEdit={(sess) => nav(`/admin/patients/${uuid}/session/${sess.uuid}/edit`)} onViewAudit={(sess) => setAuditSessionUuid(sess.uuid)} onArchive={(sess, archived) => archiveMut.mutate({ sessionUuid: sess.uuid, archived })} issuing={issueInvoiceMut.isPending} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : tab === 'appointments' ? (
|
||
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
|
||
) : tab === 'payments' ? (
|
||
<PaymentsTab q={sessionsQ} />
|
||
) : tab === 'wallet' ? (
|
||
<WalletTab uuid={uuid!} />
|
||
) : tab === 'packages' ? (
|
||
<PackagesTab uuid={uuid!} />
|
||
) : tab === 'courses' ? (
|
||
<CoursesTab uuid={uuid!} />
|
||
) : tab === 'callcenter' ? (
|
||
<CallCenterTab uuid={uuid!} />
|
||
) : tab === 'attach' ? (
|
||
<AttachmentsTab uuid={uuid!} />
|
||
) : tab === 'records' ? (
|
||
<MedicalRecordsTab uuid={uuid!} />
|
||
) : tab === 'notes' ? (
|
||
<NotesTab uuid={uuid!} />
|
||
) : (
|
||
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
|
||
)}
|
||
|
||
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
|
||
<SessionAuditModal sessionUuid={auditSessionUuid} onClose={() => setAuditSessionUuid(null)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface Attachment { uuid: string; name: string; url: string; mime?: string | null; size?: number | null }
|
||
|
||
const formatBytes = (n?: number | null) => {
|
||
if (!n) return '';
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
};
|
||
|
||
/** ضمیمه — patient attachments: upload (raw body), list, delete. */
|
||
function AttachmentsTab({ uuid }: { uuid: string }) {
|
||
const qc = useQueryClient();
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
const canDelete = can('patients', 'delete');
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
const { data, isLoading } = useQuery<ApiResponse<Attachment[]>>({
|
||
queryKey: ['patient-attachments', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/attachments`),
|
||
});
|
||
const items = data?.data ?? [];
|
||
|
||
const del = useMutation({
|
||
mutationFn: (attUuid: string) => api.delete(`/api/v1/patient/attachment/${attUuid}`),
|
||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); toast.success('ضمیمه حذف شد'); },
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const onFile = async (file: File) => {
|
||
setUploading(true);
|
||
try {
|
||
const token = useAuthStore.getState().token;
|
||
const res = await fetch(`/api/v1/patient/${uuid}/attachment?name=${encodeURIComponent(file.name)}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/octet-stream',
|
||
'Content-Disposition': `attachment; filename="${file.name}"`,
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: file,
|
||
});
|
||
if (!res.ok) throw new Error('خطا در آپلود فایل');
|
||
qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] });
|
||
toast.success('فایل آپلود شد');
|
||
} catch (e: any) {
|
||
toast.error(e.message);
|
||
} finally {
|
||
setUploading(false);
|
||
if (fileRef.current) fileRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
{canUpdate && (
|
||
<div style={{ marginBottom: 14 }}>
|
||
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : items.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز فایلی ضمیمه نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{items.map((a) => (
|
||
<div key={a.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<DocumentIcon style={{ width: 22, color: 'var(--primary)', flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<a href={a.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)', textDecoration: 'none', wordBreak: 'break-all' }}>{a.name}</a>
|
||
{a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
|
||
</div>
|
||
{canDelete && (
|
||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface MedicalItem { uuid: string; title: string; body?: string | null; recorded_at: number }
|
||
|
||
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
|
||
function MedicalRecordsTab({ uuid }: { uuid: string }) {
|
||
const qc = useQueryClient();
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
const canDelete = can('patients', 'delete');
|
||
const [modal, setModal] = useState<'create' | MedicalItem | null>(null);
|
||
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
|
||
const [title, setTitle] = useState('');
|
||
const [date, setDate] = useState('');
|
||
const [body, setBody] = useState('');
|
||
|
||
const { data, isLoading } = useQuery<ApiResponse<MedicalItem[]>>({
|
||
queryKey: ['patient-medical', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/medical-records`),
|
||
});
|
||
const items = data?.data ?? [];
|
||
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-medical', uuid] });
|
||
|
||
const open = (m?: MedicalItem) => {
|
||
setTitle(m?.title ?? ''); setBody(m?.body ?? '');
|
||
setDate(unixToIso(m?.recorded_at));
|
||
setModal(m ?? 'create');
|
||
};
|
||
|
||
const save = useMutation({
|
||
mutationFn: () => {
|
||
const payload = { title, body: body || null, recorded_at: date ? Math.floor(new Date(date).getTime() / 1000) : undefined };
|
||
return modal === 'create'
|
||
? api.post(`/api/v1/patient/${uuid}/medical-record`, payload)
|
||
: api.patch(`/api/v1/patient/medical-record/${(modal as MedicalItem).uuid}`, payload);
|
||
},
|
||
onSuccess: () => { invalidate(); setModal(null); toast.success('ذخیره شد'); },
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
const del = useMutation({
|
||
mutationFn: (u: string) => api.delete(`/api/v1/patient/medical-record/${u}`),
|
||
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('حذف شد'); },
|
||
onError: (e: any) => { toast.error(e.message); setDelTarget(null); },
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
{canUpdate && (
|
||
<div style={{ marginBottom: 14 }}>
|
||
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : items.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>معاینهای ثبت نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{items.map((m) => (
|
||
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontWeight: 700, fontSize: 14 }}>{m.title}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(m.recorded_at)}</div>
|
||
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{canUpdate && (
|
||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
|
||
)}
|
||
{canDelete && (
|
||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div>
|
||
<label className="field-label">عنوان *</label>
|
||
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /></div>
|
||
</div>
|
||
<div>
|
||
<label className="field-label">تاریخ</label>
|
||
<PersianDateInput value={date} onChange={setDate} />
|
||
</div>
|
||
<div>
|
||
<label className="field-label">شرح</label>
|
||
<div className="field" style={{ height: 'auto' }}><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn primary" disabled={!title.trim() || save.isPending} onClick={() => save.mutate()}>ذخیره</button>
|
||
<button className="btn" onClick={() => setModal(null)}>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
<ConfirmDialog
|
||
open={!!delTarget}
|
||
title="حذف معاینه"
|
||
message={`آیا از حذف «${delTarget?.title}» مطمئن هستید؟`}
|
||
confirmLabel="حذف"
|
||
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
|
||
onCancel={() => setDelTarget(null)}
|
||
loading={del.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface Note { uuid: string; body: string; pinned: boolean; author: string | null; created_at: number; updated_at: number | null }
|
||
|
||
/** آیکن سنجاق (پین) — پرشده وقتی یادداشت پین است. Heroicons سنجاق ندارد. */
|
||
function PinIcon({ filled, color, size = 15 }: { filled?: boolean; color: string; size?: number }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? color : 'none'} stroke="currentColor" strokeWidth={filled ? 0 : 1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ color }}>
|
||
<path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5Z" />
|
||
<line x1="12" y1="14" x2="12" y2="21" stroke="currentColor" strokeWidth={1.8} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
type NoteSort = 'newest' | 'oldest';
|
||
|
||
/**
|
||
* یادداشتها — personal, pinnable staff notes on the record (replaces the old
|
||
* پیامها tab). Compose box on top, then the list: pinned notes float first
|
||
* (accent rail + filled pin), each with author + Jalali date and pin/edit/delete.
|
||
*/
|
||
function NotesTab({ uuid }: { uuid: string }) {
|
||
const qc = useQueryClient();
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
const canDelete = can('patients', 'delete');
|
||
const [body, setBody] = useState('');
|
||
const [sort, setSort] = useState<NoteSort>('newest');
|
||
const [editTarget, setEditTarget] = useState<Note | null>(null);
|
||
const [editBody, setEditBody] = useState('');
|
||
const [delTarget, setDelTarget] = useState<Note | null>(null);
|
||
|
||
const { data, isLoading } = useQuery<ApiResponse<Note[]>>({
|
||
queryKey: ['patient-notes', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/notes`),
|
||
});
|
||
const items = data?.data ?? [];
|
||
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-notes', uuid] });
|
||
|
||
// سرور «پینشدهها اول، سپس جدیدترین» میدهد؛ برای «قدیمیترین» ترتیبِ غیرپین را
|
||
// معکوس میکنیم ولی پینشدهها همیشه بالا میمانند.
|
||
const shown = useMemo(() => {
|
||
const pinned = items.filter((n) => n.pinned);
|
||
const rest = items.filter((n) => !n.pinned);
|
||
const dir = (a: Note, b: Note) => (sort === 'oldest' ? a.created_at - b.created_at : b.created_at - a.created_at);
|
||
return [...pinned].sort(dir).concat([...rest].sort(dir));
|
||
}, [items, sort]);
|
||
|
||
const create = useMutation({
|
||
mutationFn: () => api.post(`/api/v1/patient/${uuid}/note`, { body: body.trim() }),
|
||
onSuccess: () => { invalidate(); setBody(''); toast.success('یادداشت ثبت شد'); },
|
||
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت یادداشت'),
|
||
});
|
||
const update = useMutation({
|
||
mutationFn: ({ u, patch }: { u: string; patch: { body?: string; pinned?: boolean } }) =>
|
||
api.patch(`/api/v1/patient/note/${u}`, patch),
|
||
onSuccess: () => { invalidate(); setEditTarget(null); },
|
||
onError: (e: any) => toast.error(e?.message || 'خطا در ویرایش یادداشت'),
|
||
});
|
||
const del = useMutation({
|
||
mutationFn: (u: string) => api.delete(`/api/v1/patient/note/${u}`),
|
||
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('یادداشت حذف شد'); },
|
||
onError: (e: any) => { toast.error(e?.message || 'خطا در حذف یادداشت'); setDelTarget(null); },
|
||
});
|
||
|
||
const openEdit = (n: Note) => { setEditTarget(n); setEditBody(n.body); };
|
||
|
||
return (
|
||
<div>
|
||
{/* افزودن یادداشت جدید */}
|
||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18, marginBottom: 18 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>افزودن یادداشت جدید</div>
|
||
<div className="field" style={{ height: 'auto', marginBottom: 12 }}>
|
||
<textarea
|
||
value={body}
|
||
onChange={(e) => setBody(e.target.value)}
|
||
rows={4}
|
||
placeholder="یادداشت خود را بنویسید..."
|
||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }}
|
||
/>
|
||
</div>
|
||
{canUpdate && (
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
|
||
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* سرآیند + مرتبسازی */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 10 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-2)' }}>
|
||
یادداشتهای قبلی ({formatNumber(items.length)})
|
||
</div>
|
||
{items.length > 0 && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مرتبسازی:</span>
|
||
{([['newest', 'جدیدترین'], ['oldest', 'قدیمیترین']] as [NoteSort, string][]).map(([key, label]) => {
|
||
const on = sort === key;
|
||
return (
|
||
<button key={key} onClick={() => setSort(key)} style={{
|
||
background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||
padding: '2px 2px 4px', fontWeight: on ? 700 : 500,
|
||
color: on ? 'var(--accent)' : 'var(--text-3)',
|
||
borderBottom: `2px solid ${on ? 'var(--accent)' : 'transparent'}`,
|
||
}}>{label}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* لیست */}
|
||
{isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : items.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>یادداشتی ثبت نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{shown.map((n) => (
|
||
<div key={n.uuid} style={{
|
||
background: n.pinned ? 'var(--accent-bg)' : 'var(--surface)',
|
||
border: '1px solid var(--border)',
|
||
borderInlineStart: `4px solid ${n.pinned ? 'var(--accent)' : 'transparent'}`,
|
||
borderRadius: 'var(--r-lg)', padding: '14px 16px',
|
||
}}>
|
||
<div style={{ fontSize: 13.5, lineHeight: 1.9, color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{n.body}</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, flexWrap: 'wrap', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 12, color: 'var(--text-3)' }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<UserIcon style={{ width: 15 }} /> {n.author || 'نامشخص'}
|
||
</span>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<CalendarDaysIcon style={{ width: 15 }} /> {formatDate(n.created_at)}
|
||
</span>
|
||
{n.updated_at && <span style={{ fontSize: 11 }}>(ویرایششده)</span>}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{canUpdate && (
|
||
<button
|
||
className="btn sm ghost"
|
||
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||
title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
|
||
disabled={update.isPending}
|
||
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
|
||
>
|
||
<PinIcon filled={n.pinned} color="currentColor" />
|
||
</button>
|
||
)}
|
||
{canUpdate && (
|
||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button>
|
||
)}
|
||
{canDelete && (
|
||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(n)}><TrashIcon style={{ width: 15 }} /></button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ویرایش */}
|
||
<Modal open={editTarget !== null} onClose={() => setEditTarget(null)} title="ویرایش یادداشت">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div className="field" style={{ height: 'auto' }}>
|
||
<textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} rows={4} autoFocus placeholder="متن یادداشت" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn primary" disabled={!editBody.trim() || update.isPending} onClick={() => editTarget && update.mutate({ u: editTarget.uuid, patch: { body: editBody.trim() } })}>ذخیره</button>
|
||
<button className="btn" onClick={() => setEditTarget(null)}>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
<ConfirmDialog
|
||
open={!!delTarget}
|
||
title="حذف یادداشت"
|
||
message="آیا از حذف این یادداشت مطمئن هستید؟"
|
||
confirmLabel="حذف"
|
||
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
|
||
onCancel={() => setDelTarget(null)}
|
||
loading={del.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface Call { uuid: string; subject: string; summary?: string | null; outcome: string; called_at: number; personnel?: string | null }
|
||
|
||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||
/** تاریخ امروز میلادی به فرمت YYYY-MM-DD (ورودی PersianDateInput). */
|
||
const nowDate = () => { const d = new Date(); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; };
|
||
/** ساعت الان به فرمت HH:MM. */
|
||
const nowTime = () => { const d = new Date(); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
|
||
|
||
/** کال سنتر — patient call log: register a call + filterable history (all / success / missed). */
|
||
function CallCenterTab({ uuid }: { uuid: string }) {
|
||
const qc = useQueryClient();
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
const canDelete = can('patients', 'delete');
|
||
const userName = useAuthStore((s) => s.userName);
|
||
const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all');
|
||
const [date, setDate] = useState(nowDate);
|
||
const [time, setTime] = useState(nowTime);
|
||
const [subject, setSubject] = useState('');
|
||
const [summary, setSummary] = useState('');
|
||
const [outcome, setOutcome] = useState<'success' | 'missed'>('success');
|
||
|
||
// Keep تاریخ/ساعت تماس live while the user hasn't manually edited them, so a
|
||
// long-open tab doesn't submit a stale time without a page refresh.
|
||
const dateTouched = useRef(false);
|
||
const timeTouched = useRef(false);
|
||
useEffect(() => {
|
||
const sync = () => {
|
||
if (!dateTouched.current) setDate(nowDate());
|
||
if (!timeTouched.current) setTime(nowTime());
|
||
};
|
||
const id = window.setInterval(sync, 30_000);
|
||
window.addEventListener('focus', sync);
|
||
return () => { window.clearInterval(id); window.removeEventListener('focus', sync); };
|
||
}, []);
|
||
|
||
const { data, isLoading } = useQuery<ApiResponse<Call[]>>({
|
||
queryKey: ['patient-calls', uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${uuid}/calls`),
|
||
});
|
||
const calls = data?.data ?? [];
|
||
const shown = filter === 'all' ? calls : calls.filter((c) => c.outcome === filter);
|
||
const successCount = calls.filter((c) => c.outcome === 'success').length;
|
||
const missedCount = calls.filter((c) => c.outcome === 'missed').length;
|
||
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-calls', uuid] });
|
||
|
||
const create = useMutation({
|
||
mutationFn: () => {
|
||
const iso = date ? `${date}T${time || '00:00'}` : null;
|
||
const calledAt = iso ? Math.floor(new Date(iso).getTime() / 1000) : Math.floor(Date.now() / 1000);
|
||
return api.post(`/api/v1/patient/${uuid}/call`, { subject, summary, outcome, called_at: calledAt, personnel: userName });
|
||
},
|
||
onSuccess: () => { invalidate(); dateTouched.current = false; timeTouched.current = false; setDate(nowDate()); setTime(nowTime()); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); },
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
const del = useMutation({
|
||
mutationFn: (u: string) => api.delete(`/api/v1/patient/call/${u}`),
|
||
onSuccess: () => { invalidate(); toast.success('تماس حذف شد'); },
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const chip = (key: 'all' | 'success' | 'missed', label: string) => (
|
||
<button onClick={() => setFilter(key)} style={{
|
||
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
|
||
fontFamily: 'inherit', fontSize: 13, fontWeight: filter === key ? 700 : 500,
|
||
background: filter === key ? 'var(--primary-soft)' : 'var(--surface)',
|
||
color: filter === key ? 'var(--primary)' : 'var(--text-2)',
|
||
}}>{label}</button>
|
||
);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||
{/* register form */}
|
||
<div style={{ width: 320, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, textAlign: 'center', marginBottom: 16 }}>ثبت تماس جدید</div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تاریخ تماس</label>
|
||
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={(v) => { dateTouched.current = true; setDate(v); }} /></div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت تماس</label>
|
||
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => { timeTouched.current = true; setTime(e.target.value); }} dir="ltr" /></div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>موضوع تماس</label>
|
||
<div className="field" style={{ margin: '6px 0 12px' }}><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="موضوع تماس" /></div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>خلاصه تماس</label>
|
||
<div className="field" style={{ height: 'auto', margin: '6px 0 12px' }}><textarea value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} placeholder="خلاصه تماس" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||
<button onClick={() => setOutcome('success')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'success' ? 'var(--success-bg)' : 'var(--surface)', color: outcome === 'success' ? 'var(--success)' : 'var(--text-2)', fontWeight: outcome === 'success' ? 700 : 500 }}>موفق</button>
|
||
<button onClick={() => setOutcome('missed')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'missed' ? 'var(--danger-bg)' : 'var(--surface)', color: outcome === 'missed' ? 'var(--danger)' : 'var(--text-2)', fontWeight: outcome === 'missed' ? 700 : 500 }}>بیپاسخ</button>
|
||
</div>
|
||
{canUpdate && (
|
||
<button className="btn primary" style={{ width: '100%' }} disabled={!subject.trim() || create.isPending} onClick={() => create.mutate()}><PlusIcon style={{ width: 16 }} /> ثبت تماس</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* history */}
|
||
<div style={{ flex: 1, minWidth: 320 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{chip('all', 'همه')}
|
||
{chip('success', `تماسهای موفق (${successCount})`)}
|
||
{chip('missed', `بیپاسخ (${missedCount})`)}
|
||
</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700 }}>تاریخچه تماسها</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : shown.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تماسی ثبت نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{shown.map((c) => {
|
||
const ok = c.outcome === 'success';
|
||
return (
|
||
<div key={c.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderInlineStart: `4px solid ${ok ? 'var(--success)' : 'var(--danger)'}`, borderRadius: 'var(--r-lg)', padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<PhoneArrowUpRightIcon style={{ width: 16, color: ok ? 'var(--success)' : 'var(--danger)' }} />
|
||
<span style={{ fontSize: 13.5, fontWeight: 700 }}>{c.subject}</span>
|
||
</div>
|
||
{c.summary && <div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 4 }}>{c.summary}</div>}
|
||
</div>
|
||
<div style={{ textAlign: 'end', minWidth: 120 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(c.called_at)}</div>
|
||
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
|
||
{canDelete && (
|
||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type WalletFilter = 'all' | 'credit' | 'debit';
|
||
|
||
const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
|
||
{ key: 'all', label: 'همه' },
|
||
{ key: 'credit', label: 'واریزی' },
|
||
{ key: 'debit', label: 'برداشت' },
|
||
];
|
||
|
||
const METHOD_LABEL: Record<string, string> = {
|
||
card: 'کارت به کارت', pos: 'کارتخوان', cash: 'نقدی', wallet: 'کیف پول', gateway: 'درگاه اینترنتی',
|
||
};
|
||
const STATUS_LABEL: Record<string, string> = { confirmed: 'تأیید شده' };
|
||
|
||
type WalletRow = WalletTxn & { row_no: number };
|
||
|
||
/**
|
||
* کیف پول — کارت موجودی + مودال شارژ/برداشت (روش پرداخت واقعی) + فیلتر
|
||
* همه/واریزی/برداشت روی دفترِ کاملِ تراکنشها (DataTable با ستون ثبتکننده،
|
||
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
|
||
*/
|
||
function WalletTab({ uuid }: { uuid: string }) {
|
||
const { can } = usePermissions();
|
||
const canUpdate = can('patients', 'update');
|
||
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [filter, setFilter] = useState<WalletFilter>('all');
|
||
|
||
const submitting = charge.isPending || withdraw.isPending;
|
||
|
||
const handleSubmit = ({ mode, ...body }: WalletModalSubmit) => {
|
||
const mut = mode === 'charge' ? charge : withdraw;
|
||
mut.mutate(body, {
|
||
onSuccess: () => {
|
||
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
|
||
setModalOpen(false);
|
||
},
|
||
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
|
||
});
|
||
};
|
||
|
||
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
||
const rows: WalletRow[] = shown.map((t, i) => ({ ...t, row_no: i + 1 }));
|
||
|
||
const columns: Column<WalletRow>[] = [
|
||
{ key: 'row_no', header: 'ردیف', className: 'w-[60px]', render: (r) => formatNumber(r.row_no) },
|
||
{
|
||
key: 'amount', header: 'مبلغ', render: (r) => (
|
||
<span style={{ fontWeight: 700, direction: 'ltr', color: r.type === 'credit' ? 'var(--success)' : 'var(--danger)' }}>
|
||
{r.type === 'credit' ? '+' : '−'}{formatRial(r.amount_rials)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'type', header: 'نوع تراکنش', render: (r) => (
|
||
<span className={`badge ${r.type === 'credit' ? 'green' : 'red'}`}>{r.type === 'credit' ? 'واریز' : 'برداشت'}</span>
|
||
),
|
||
},
|
||
{ key: 'method', header: 'روش پرداخت', render: (r) => (r.payment_method ? METHOD_LABEL[r.payment_method] ?? r.payment_method : '—') },
|
||
{ key: 'reason', header: 'دلیل', render: (r) => r.description || '—' },
|
||
{ key: 'by', header: 'ثبتکننده', render: (r) => r.created_by_name || '—' },
|
||
{ key: 'date', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
||
{ key: 'time', header: 'ساعت', render: (r) => formatTime(r.created_at) },
|
||
{ key: 'status', header: 'وضعیت', render: (r) => <span className="badge gray">{STATUS_LABEL[r.status ?? ''] ?? 'تأیید شده'}</span> },
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* موجودی + دکمه تراکنش جدید */}
|
||
<div className="card card-pad" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
|
||
{/* موجودی کلِ بیمار است ولی فهرست فقط تراکنشهای همین محیط را میآورد؛
|
||
بدون این توضیح، اختلافِ جمعِ سطرها با موجودی شبیه باگ دیده میشود. */}
|
||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
|
||
موجودی کل بیمار؛ تراکنشهای زیر فقط مربوط به همین محل کار است
|
||
</div>
|
||
</div>
|
||
{canUpdate && (
|
||
<button className="btn primary" onClick={() => setModalOpen(true)}>
|
||
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* فیلتر تراکنشها */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنشها:</span>
|
||
{WALLET_FILTERS.map((f) => {
|
||
const on = filter === f.key;
|
||
return (
|
||
<button key={f.key} onClick={() => setFilter(f.key)} style={{
|
||
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
|
||
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
|
||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||
color: on ? 'var(--primary)' : 'var(--text-2)',
|
||
}}>{f.label}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<WalletTransactionModal
|
||
open={modalOpen}
|
||
balanceRials={balanceRials}
|
||
submitting={submitting}
|
||
onClose={() => setModalOpen(false)}
|
||
onSubmit={handleSubmit}
|
||
/>
|
||
|
||
<DataTable<WalletRow>
|
||
columns={columns}
|
||
data={rows}
|
||
loading={isLoading}
|
||
emptyMessage="تراکنشی ثبت نشده است"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* پرداختها — settlement history grouped by مراجعه (session), ported from tauri
|
||
* PaymentsSection. Reuses the already-fetched sessions; each session is an
|
||
* accordion showing its settlement line or the empty message.
|
||
*/
|
||
function PaymentsTab({ q }: { q: { data?: ApiResponse<any[]>; isLoading: boolean } }) {
|
||
const items = (q.data?.data ?? []) as SessionPaymentData[];
|
||
// undefined = default (first panel open, matching tauri); null = user closed all.
|
||
const [expanded, setExpanded] = useState<string | null | undefined>(undefined);
|
||
const openUuid = expanded === undefined ? (items[0]?.uuid ?? null) : expanded;
|
||
|
||
if (q.isLoading) return <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||
if (items.length === 0) return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>پرداختی ثبت نشده است</div>;
|
||
|
||
return (
|
||
<div>
|
||
{items.map((s) => (
|
||
<SessionPaymentAccordion
|
||
key={s.uuid}
|
||
session={s}
|
||
expanded={openUuid === s.uuid}
|
||
onToggle={() => setExpanded(openUuid === s.uuid ? null : s.uuid)}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const APPT_SORT_OPTS = [
|
||
{ value: 'newest', label: 'جدیدترین' },
|
||
{ value: 'oldest', label: 'قدیمیترین' },
|
||
{ value: 'reserved', label: 'رزرو شده' },
|
||
{ value: 'done', label: 'انجام شده' },
|
||
{ value: 'cancelled', label: 'لغو شده' },
|
||
];
|
||
|
||
const CANCELLED_STATUSES = ['cancelled_by_doctor', 'cancelled_by_user', 'no_show', 'expired'];
|
||
|
||
/**
|
||
* نوبتها — the appointments tab, ported from tauri TurnsSection: a sort/filter
|
||
* toolbar + reserve/new buttons, over a grid of AppointmentTurnCard. Sorting and
|
||
* filtering are client-side over the already-fetched list (tauri leaves them inert).
|
||
*/
|
||
function AppointmentsTab({ uuid, q }: {
|
||
uuid: string;
|
||
q: { data?: ApiResponse<AppointmentCardData[]>; isLoading: boolean };
|
||
}) {
|
||
const [sort, setSort] = useState('newest');
|
||
const items = q.data?.data ?? [];
|
||
const queryKey = ['patient-appointments', uuid];
|
||
|
||
const shown = useMemo(() => {
|
||
let list = [...items];
|
||
if (sort === 'reserved') list = list.filter((a) => a.status !== 'completed' && !CANCELLED_STATUSES.includes(a.status));
|
||
else if (sort === 'done') list = list.filter((a) => a.status === 'completed');
|
||
else if (sort === 'cancelled') list = list.filter((a) => CANCELLED_STATUSES.includes(a.status));
|
||
list.sort((a, b) => (sort === 'oldest' ? a.starts_at - b.starts_at : b.starts_at - a.starts_at));
|
||
return list;
|
||
}, [items, sort]);
|
||
|
||
return (
|
||
<div>
|
||
{/* toolbar: sort + filter (right of RTL) · reserve/new buttons (left) */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{ width: 320, maxWidth: '100%' }}>
|
||
<SearchableSelect options={APPT_SORT_OPTS} value={sort} onChange={(v) => setSort(String(v ?? 'newest'))} height={48} />
|
||
</div>
|
||
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent' }}>
|
||
<TurnsFilter color="var(--primary)" />
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid var(--primary)', color: 'var(--primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||
<AddTurn color="var(--primary)" /> نوبت رزرو
|
||
</Link>
|
||
{/* `record` یعنی مراجعهکننده از همین پرونده میآید؛ صفحهٔ ثبت نوبت دیگر جستجو نمیخواهد. */}
|
||
<Link to={`/admin/appointments/new?record=${uuid}`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||
<AddTurn color="var(--on-primary)" /> نوبت جدید
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{q.isLoading ? (
|
||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||
) : shown.length === 0 ? (
|
||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>نوبتی ثبت نشده است</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||
{shown.map((a) => <AppointmentTurnCard key={a.uuid} appointment={a} queryKey={queryKey} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
/**
|
||
* پکیجهای بیمار.
|
||
*
|
||
* مانده از دفتر میآید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان میدهد تا
|
||
* معلوم باشد عدد از کجا آمده.
|
||
*/
|
||
function PackagesTab({ uuid }: { uuid: string }) {
|
||
const { patientPackages, loading, sell } = usePatientPackages(uuid);
|
||
const { packages } = usePackages();
|
||
const [selected, setSelected] = useState('');
|
||
|
||
const active = packages.filter((p) => p.active);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||
<label>فروش پکیج تازه</label>
|
||
<SearchableSelect
|
||
value={selected}
|
||
onChange={(v) => setSelected(String(v ?? ''))}
|
||
options={active.map((p) => ({
|
||
value: p.uuid,
|
||
label: `${p.name} — ${p.session_count} جلسه`,
|
||
}))}
|
||
placeholder="انتخاب پکیج"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={selected === '' || sell.isPending}
|
||
onClick={async () => {
|
||
await sell.mutateAsync({ package_uuid: selected });
|
||
setSelected('');
|
||
}}
|
||
>
|
||
ثبت خرید
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||
) : patientPackages.length === 0 ? (
|
||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||
این بیمار هنوز پکیجی نخریده است
|
||
</div>
|
||
) : (
|
||
patientPackages.map((p) => (
|
||
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
|
||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||
خرید {formatDate(p.purchased_at)}
|
||
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
|
||
</span>
|
||
</div>
|
||
|
||
<span style={{ fontSize: 14 }}>
|
||
مانده: <strong>{p.balance}</strong> از {p.session_count}
|
||
</span>
|
||
|
||
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
|
||
{!p.expired && p.balance === 0 && (
|
||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه میشود.
|
||
</span>
|
||
)}
|
||
|
||
<Link
|
||
className="btn secondary sm"
|
||
style={{ marginRight: 'auto' }}
|
||
to={`/admin/patient-package/${p.uuid}/ledger`}
|
||
>
|
||
دفتر اعتبار
|
||
</Link>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* دورههای درمان بیمار.
|
||
*
|
||
* پیشرفت از سرور میآید («۳ از ۸»)؛ فرانت نمیشمارد، چون وضعیت جلسات آنجاست.
|
||
*/
|
||
function CoursesTab({ uuid }: { uuid: string }) {
|
||
const { courses, loading, start } = usePatientCourses(uuid);
|
||
const { protocols } = useCourseProtocols();
|
||
const [selected, setSelected] = useState('');
|
||
|
||
const active = protocols.filter((p) => p.active);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||
<label>شروع دورهٔ تازه</label>
|
||
<SearchableSelect
|
||
value={selected}
|
||
onChange={(v) => setSelected(String(v ?? ''))}
|
||
options={active.map((p) => ({
|
||
value: p.uuid,
|
||
label: `${p.service_name} — ${p.session_count} جلسه`,
|
||
}))}
|
||
placeholder="انتخاب پروتکل"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={selected === '' || start.isPending}
|
||
onClick={async () => {
|
||
await start.mutateAsync({ protocol_uuid: selected });
|
||
setSelected('');
|
||
}}
|
||
>
|
||
شروع دوره
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||
) : courses.length === 0 ? (
|
||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||
این بیمار دورهٔ درمانی ندارد
|
||
</div>
|
||
) : (
|
||
courses.map((c) => (
|
||
<div key={c.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<span style={{ fontWeight: 600 }}>{c.service_name}</span>
|
||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>شروع {formatDate(c.started_at)}</span>
|
||
</div>
|
||
|
||
<span style={{ fontSize: 14 }}>
|
||
جلسهٔ <strong>{c.progress.completed}</strong> از {c.progress.total}
|
||
</span>
|
||
|
||
{c.status === 'active' ? (
|
||
<span className="badge green"><span className="bdot" />در جریان</span>
|
||
) : (
|
||
<span className="badge"><span className="bdot" />{c.status === 'completed' ? 'تمامشده' : 'رهاشده'}</span>
|
||
)}
|
||
|
||
<Link className="btn secondary sm" style={{ marginRight: 'auto' }} to={`/admin/treatment-course/${c.uuid}`}>
|
||
جزئیات دوره
|
||
</Link>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|