- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL. - Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages. - Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values. - Add SlotPicker component for selecting appointment slots based on availability. - Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL. - Update API documentation to reflect changes in appointment creation and slot selection processes.
1024 lines
53 KiB
TypeScript
1024 lines
53 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,
|
||
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
|
||
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
||
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||
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' | '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: '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 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}
|
||
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 === '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>
|
||
);
|
||
}
|
||
|