Oleada B: fusion Expedientes en Pacientes (+chip sin visita 6m), sidebar colapsable SKEEN, edicion completa de citas, autocompletado de paciente, bloqueos excluidos de slots
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import {
|
||||
@@ -69,6 +69,7 @@ const Agenda: FC = () => {
|
||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
|
||||
@@ -92,6 +93,12 @@ const Agenda: FC = () => {
|
||||
sessions: 1,
|
||||
});
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
const quickToggleClass = editingId
|
||||
? 'hidden'
|
||||
: 'inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer';
|
||||
const [crearNuevo, setCrearNuevo] = useState(false);
|
||||
const [nuevoPac, setNuevoPac] = useState({ name: '', phone: '' });
|
||||
const [creandoPac, setCreandoPac] = useState(false);
|
||||
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
|
||||
const [quickMode, setQuickMode] = useState(false);
|
||||
const [quickForm, setQuickForm] = useState({
|
||||
@@ -533,7 +540,7 @@ const Agenda: FC = () => {
|
||||
if (!createOpen) return;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
|
||||
const res = await odooApi.getPatients({ search: patientSearch, page_size: 8 });
|
||||
if (res.status === 'success') setPatients(res.patients);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -542,6 +549,36 @@ const Agenda: FC = () => {
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
// Crear paciente nuevo desde el selector del modal y seleccionarlo
|
||||
const crearYSeleccionarPaciente = async () => {
|
||||
if (!nuevoPac.name.trim() || !nuevoPac.phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreandoPac(true);
|
||||
const res = await odooApi.createPatient({
|
||||
name: nuevoPac.name.trim(),
|
||||
phone: nuevoPac.phone.trim(),
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
toast.success('Paciente creado');
|
||||
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
|
||||
setPatients((prev) => [
|
||||
res.patient,
|
||||
...prev.filter((p) => String(p.id) !== String(res.patient.id)),
|
||||
]);
|
||||
setCrearNuevo(false);
|
||||
setPatientSearch('');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al crear el paciente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCreandoPac(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
|
||||
if (!serviceId || !aptDate) {
|
||||
setSlots([]);
|
||||
@@ -743,8 +780,59 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Editar una cita existente: abre el modal de creación precargado y guarda con PUT.
|
||||
const openEditCita = (apt: Appointment) => {
|
||||
setQuickMode(false);
|
||||
setDetailApt(null);
|
||||
setEditMode(false);
|
||||
setEditingId(apt.id);
|
||||
setForm({
|
||||
patient_id: String(apt.patient_id),
|
||||
service_id: String(apt.service_id),
|
||||
doctor_id: apt.doctor_id ? String(apt.doctor_id) : '',
|
||||
date: apt.date,
|
||||
time: apt.time,
|
||||
notes: apt.notes || '',
|
||||
sessions: 1,
|
||||
});
|
||||
setPatientSearch('');
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const saveEditCita = async () => {
|
||||
if (!editingId) return;
|
||||
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
|
||||
toast.error('Completa los campos obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.updateAppointment(editingId, {
|
||||
partner_id: parseInt(form.patient_id, 10),
|
||||
servicio_id: parseInt(form.service_id, 10),
|
||||
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
|
||||
date: form.date,
|
||||
time: parseTimeToFloat(form.time) ?? 0,
|
||||
notes: form.notes,
|
||||
} as never);
|
||||
toast.success('Cita actualizada');
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
setDetailApt(null);
|
||||
setEditMode(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo actualizar la cita');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
if (medicoEmployeeId) {
|
||||
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
|
||||
}
|
||||
@@ -765,6 +853,7 @@ const Agenda: FC = () => {
|
||||
const justDroppedRef = useRef(false);
|
||||
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
const d = opts?.date ?? date;
|
||||
const docId = medicoEmployeeId
|
||||
? String(medicoEmployeeId)
|
||||
@@ -826,10 +915,23 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
const patientOptions = useMemo(() => {
|
||||
const opts: { value: string; label: string }[] = [{ value: '', label: 'Seleccionar paciente...' }];
|
||||
const list = patients.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.name} — ${p.phone || 'sin teléfono'}${p.primary_doctor ? ` · ${p.primary_doctor}` : ''}`,
|
||||
}));
|
||||
// Garantiza que el paciente precargado (desde celda o edición) aparezca
|
||||
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
|
||||
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id) as Appointment | undefined;
|
||||
opts.push({ value: form.patient_id, label: sel ? `${sel.patient} — (cita seleccionada)` : `Paciente #${form.patient_id}` });
|
||||
}
|
||||
opts.push(...list);
|
||||
if (patientSearch.trim()) {
|
||||
opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` });
|
||||
}
|
||||
return opts;
|
||||
}, [patients, patientSearch, form.patient_id, appointments]);
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Seleccionar servicio...' },
|
||||
@@ -1755,20 +1857,20 @@ const Agenda: FC = () => {
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nueva cita"
|
||||
onClose={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}
|
||||
title={editingId ? 'Editar cita' : 'Nueva cita'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={quickMode ? submitQuick : createAppointment} loading={submitting}>
|
||||
{quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
|
||||
<Button variant="outline" onClick={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}>Cancelar</Button>
|
||||
<Button onClick={editingId ? saveEditCita : quickMode ? submitQuick : createAppointment} loading={submitting}>
|
||||
{editingId ? 'Guardar cambios' : quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<label className={quickToggleClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={quickMode}
|
||||
@@ -1829,11 +1931,45 @@ const Agenda: FC = () => {
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
/>
|
||||
{crearNuevo && (
|
||||
<div className="rounded-xl border border-theme-border p-3 space-y-3 bg-theme-bg">
|
||||
<p className="text-xs text-theme-muted">Nuevo paciente: captura nombre y teléfono para crearlo y agendarle.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
placeholder="Nombre del paciente"
|
||||
value={nuevoPac.name}
|
||||
onChange={(e) => setNuevoPac({ ...nuevoPac, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Teléfono *"
|
||||
placeholder="+52 ..."
|
||||
value={nuevoPac.phone}
|
||||
onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
|
||||
Crear y seleccionar
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setCrearNuevo(false); setForm((f) => ({ ...f, patient_id: '' })); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={form.patient_id}
|
||||
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__crear__') {
|
||||
setCrearNuevo(true);
|
||||
return;
|
||||
}
|
||||
setCrearNuevo(false);
|
||||
setForm({ ...form, patient_id: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Servicio *"
|
||||
@@ -2102,6 +2238,9 @@ const Agenda: FC = () => {
|
||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
|
||||
<Pencil size={16} className="mr-1.5" /> Editar
|
||||
</Button>
|
||||
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
|
||||
Solo en estados cobrables (oculto en cancelada/no show). */}
|
||||
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
||||
|
||||
@@ -135,6 +135,7 @@ const Pacientes: FC = () => {
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||
const [onlyVip, setOnlyVip] = useState(false);
|
||||
const [onlyRecent, setOnlyRecent] = useState(false);
|
||||
const [onlyStale, setOnlyStale] = useState(false);
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -198,7 +199,7 @@ const Pacientes: FC = () => {
|
||||
page_size: pageSize,
|
||||
incomplete: onlyIncomplete ? 1 : undefined,
|
||||
vip: onlyVip ? 1 : undefined,
|
||||
recent: onlyRecent ? 1 : undefined,
|
||||
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
|
||||
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
@@ -213,11 +214,11 @@ const Pacientes: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
||||
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
@@ -458,7 +459,8 @@ const Pacientes: FC = () => {
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||
{([
|
||||
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
|
||||
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: setOnlyRecent },
|
||||
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
|
||||
{ key: 'stale', label: 'Sin visita +6 meses', active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
|
||||
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
|
||||
] as const).map((chip) => (
|
||||
<button
|
||||
@@ -474,10 +476,10 @@ const Pacientes: FC = () => {
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
{(onlyVip || onlyRecent || onlyIncomplete || doctorFilter) && (
|
||||
{(onlyVip || onlyRecent || onlyStale || onlyIncomplete || doctorFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
|
||||
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
|
||||
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||
>
|
||||
Quitar filtros
|
||||
|
||||
Reference in New Issue
Block a user