// Patients list + Patient detail — wired to real API.
const PAY_METHODS = [
{ id: 'cash', label: "Naqd" },
{ id: 'card', label: 'Karta' },
{ id: 'transfer', label: "O'tkazma" },
{ id: 'debt', label: 'Qarz' },
{ id: 'mixed', label: 'Aralash' },
];
function EditVisitModal({ open, visit, onClose, onSaved, toast }) {
const [doctorId, setDoctorId] = useState('');
const [paymentMethod, setPaymentMethod] = useState('cash');
const [notes, setNotes] = useState('');
const [selectedSvcs, setSelectedSvcs] = useState([]); // [{service_id, quantity, name, price}]
const [doctors, setDoctors] = useState([]);
const [allServices, setAllServices] = useState([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!open || !visit) return;
setLoading(true);
Promise.all([
window.API.listDoctors(),
window.API.listServices(),
]).then(([docs, svcs]) => {
setDoctors(docs);
setAllServices(svcs);
setDoctorId(visit.doctor?.id ? String(visit.doctor.id) : '');
setPaymentMethod(visit.payment_method || 'cash');
setNotes(visit.notes || '');
const items = (visit.items || []).map(it => ({
service_id: it.service_id,
quantity: it.quantity,
name: it.service?.name || `#${it.service_id}`,
price: Number(it.service?.price || it.price_at_moment),
}));
setSelectedSvcs(items);
setLoading(false);
}).catch(err => {
toast?.({ kind: 'danger', title: 'Yuklash xato', msg: err.message });
setLoading(false);
});
}, [open, visit?.id]);
const total = selectedSvcs.reduce((s, x) => s + Number(x.price) * x.quantity, 0);
const toggleSvc = (svc) => {
setSelectedSvcs(prev => {
const exists = prev.find(x => x.service_id === svc.id);
if (exists) return prev.filter(x => x.service_id !== svc.id);
return [...prev, { service_id: svc.id, quantity: 1, name: svc.name, price: Number(svc.price) }];
});
};
const setQty = (sid, q) => {
const n = Math.max(1, parseInt(q) || 1);
setSelectedSvcs(prev => prev.map(x => x.service_id === sid ? { ...x, quantity: n } : x));
};
const save = async () => {
if (selectedSvcs.length === 0) {
toast?.({ kind: 'warning', title: "Kamida 1 ta xizmat tanlang" });
return;
}
setSaving(true);
try {
const payload = {
doctor_id: doctorId ? Number(doctorId) : null,
doctor_clear: !doctorId,
services: selectedSvcs.map(s => ({ service_id: s.service_id, quantity: s.quantity })),
payment_method: paymentMethod,
notes: notes || null,
};
await window.API.updateVisit(visit.id, payload);
toast?.({ kind: 'success', title: 'Saqlandi' });
onSaved?.();
} catch (err) {
toast?.({ kind: 'danger', title: 'Saqlash xato', msg: err.message });
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
>}>
{loading ? (
Yuklanmoqda...
) : (
)}
);
}
function PatientsPage({ onNav, toast }) {
const [q, setQ] = useState('');
const [view, setView] = useState('table');
const [pageNum, setPageNum] = useState(1);
const pageSize = 12;
const [patients, setPatients] = useState([]);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const me = (window.Auth && window.Auth.getUser && window.Auth.getUser()) || null;
const canDelete = me && (me.role === 'admin' || me.role === 'super_admin');
// Server-side search with debounce
useEffect(() => {
let mounted = true;
setLoading(true);
const t = setTimeout(() => {
window.API.listPatients(q ? { q, limit: 200 } : { limit: 200 })
.then(rows => { if (mounted) { setPatients(rows.map(window.normalizePatient)); setLoading(false); } })
.catch(err => { if (mounted) { setLoading(false); console.error(err); } });
}, q ? 250 : 0);
return () => { mounted = false; clearTimeout(t); };
}, [q, reloadKey]);
const deletePatient = async (p, e) => {
e?.stopPropagation();
if (!confirm(`"${p.fullName || p.name}" bemorini butunlay o'chirmoqchimisiz?\n\nAgar bemorda qabullar bo'lsa, avval qabullarni o'chiring.`)) return;
try {
await window.API.deletePatient(p.id);
toast?.({ kind: 'success', title: "Bemor o'chirildi", msg: p.fullName || p.name });
setReloadKey(k => k + 1);
} catch (err) {
toast?.({ kind: 'danger', title: "O'chirib bo'lmadi", msg: err.message });
}
};
const filtered = patients;
const paginated = filtered.slice((pageNum - 1) * pageSize, pageNum * pageSize);
return (
onNav('new-visit')}>Yangi rentgen
}
/>
{!loading && paginated.length === 0 ? (
) : view === 'table' ? (
| F.I.O. |
Telefon |
Tug'ilgan |
Yosh |
Amallar |
{paginated.map(p => (
onNav('patient-detail', { patientId: p.id })}>
{highlight(p.fullName || p.name, q)}
{p.passport && {p.passport} }
|
{p.phone ? highlight(p.phone, q) : '—'} |
{p.birth} |
{p.birthDate ? {age(p.birthDate)} yosh : '—'} |
{canDelete && (
)}
|
))}
) : (
{paginated.map(p => (
onNav('patient-detail', { patientId: p.id })}>
{p.name}
{p.birthDate &&
{age(p.birthDate)} yosh
}
{p.phone && (
{p.phone}
)}
))}
)}
{paginated.length > 0 && }
);
}
/* =========================================================
PATIENT DETAIL
========================================================= */
function PatientDetailPage({ onNav, patientId, toast }) {
const [patient, setPatient] = useState(null);
const [visits, setVisits] = useState([]);
const [tab, setTab] = useState('visits');
const [loading, setLoading] = useState(true);
const [editingVisit, setEditingVisit] = useState(null);
const me = (window.Auth && window.Auth.getUser && window.Auth.getUser()) || null;
const canEditVisits = me && (me.role === 'admin' || me.role === 'super_admin');
const reload = () => {
Promise.all([
window.API.getPatient(patientId),
window.API.listXrayRecords({ limit: 200 }),
]).then(([p, allVisits]) => {
setPatient(window.normalizePatient(p));
// Fetch each visit fully to get items (services) for the edit modal.
// Listing endpoint returns VisitListItem (no items), so we just take what we have.
// Items will be fetched on-demand when opening edit.
setVisits(allVisits.filter(v => v.patient.id === p.id));
setLoading(false);
}).catch(() => setLoading(false));
};
useEffect(() => {
setLoading(true);
reload();
}, [patientId]);
const openEdit = async (v) => {
try {
const full = await window.API.getVisit(v.id);
setEditingVisit(full);
} catch (err) {
toast?.({ kind: 'danger', title: 'Yuklash xato', msg: err.message });
}
};
const deleteVisit = async (v) => {
if (!confirm(`Qabul #${v.id} ni butunlay o'chirmoqchimisiz?\n\nXizmatlar, to'lovlar va rentgen yozuv ham o'chiriladi. Bu amalni qaytarib bo'lmaydi.\n\nTelegram'dagi ZIP fayl saqlanib qoladi.`)) return;
try {
await window.API.deleteVisit(v.id);
toast?.({ kind: 'success', title: "Qabul o'chirildi" });
reload();
} catch (err) {
toast?.({ kind: 'danger', title: "O'chirib bo'lmadi", msg: err.message });
}
};
if (loading || !patient) return Yuklanmoqda...
;
const totalSpent = visits.reduce((s, v) => s + Number(v.paid_amount), 0);
const totalDebt = visits.reduce((s, v) => s + (Number(v.total_amount) - Number(v.paid_amount)), 0);
return (
onNav('patients')} key="b">Bemorlar, patient.name]}
actions={
}
/>
{patient.fullName || patient.name}
{patient.birthDate ? `${age(patient.birthDate)} yosh · ` : ''}Bemor
{patient.phone && }
{patient.passport && }
Moliyaviy
To'langan
{uzMoneyShort(totalSpent)} so'm
Qarz
0 ? 'var(--color-danger)' : 'var(--fg-3)', fontVariantNumeric: 'tabular-nums' }}>{uzMoneyShort(totalDebt)} so'm
setEditingVisit(null)}
onSaved={() => { setEditingVisit(null); reload(); }}
/>
);
}
function InfoRow({ icon, label, value, mono }) {
return (
);
}
function InfoTile({ icon, label, value }) {
return (
);
}
/* =========================================================
3D X-RAY PREVIEW (mock — CSS+SVG art that conveys CBCT viewer)
========================================================= */
function XrayPreview3D() {
const [rotate, setRotate] = useState(15);
const [zoom, setZoom] = useState(1);
return (
X: {Math.round(rotate)}°
Z: {zoom.toFixed(2)}x
● LIVE
VIEW: PANORAMIC — SLICE 28/64
setRotate(parseInt(e.target.value))} />
);
}
function ToothScanSVG() {
return (
);
}
Object.assign(window, { PatientsPage, PatientDetailPage, EditVisitModal, XrayPreview3D, ToothScanSVG, InfoRow, InfoTile });