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:
2026-09-11 10:38:08 +00:00
parent 0b45abc197
commit 4f49188deb
9 changed files with 295 additions and 65 deletions

View File

@@ -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) && (