Oleada UX 2: Agenda (mensajes de conflicto humanos, imprimir agenda del dia, confirmacion en lote, prefs persistentes) y Pacientes (acciones en ficha, timeline unificada, aviso de telefono duplicado) + fixes de precarga e impresion
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { FC } from '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 { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import AgendaPrint from '../components/AgendaPrint';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -30,6 +31,63 @@ const stateOptions = [
|
||||
{ value: 'cancelled', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
// ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ----
|
||||
const AGENDA_PREFS_KEY = 'skeen_agenda_prefs';
|
||||
type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana';
|
||||
interface AgendaPrefs {
|
||||
viewMode?: AgendaViewMode;
|
||||
doctorFilter?: string;
|
||||
staffTypeFilter?: '' | 'doctor' | 'cosmetologa';
|
||||
}
|
||||
const readAgendaPrefs = (): AgendaPrefs => {
|
||||
try {
|
||||
const raw = localStorage.getItem(AGENDA_PREFS_KEY);
|
||||
return raw ? (JSON.parse(raw) as AgendaPrefs) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_VIEW_MODES: AgendaViewMode[] = ['lista', 'doctores', 'horas', 'semana'];
|
||||
|
||||
// ---- Conflictos de agenda (solape / bloqueo) en lenguaje humano ----
|
||||
export interface AgendaConflict {
|
||||
kind: 'overlap' | 'bloqueo';
|
||||
message: string;
|
||||
conflicting?: { id: number; time: string; patient: string; doctor: string } | null;
|
||||
}
|
||||
|
||||
interface ApiErrorShape {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
conflicting_appointment?: { id: number; time: string; patient: string; doctor: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Traduce los mensajes técnicos del backend (validaciones de skeen.cita) a
|
||||
// texto entendible; null si el error no es un conflicto de agenda.
|
||||
const parseAgendaConflict = (err: unknown): AgendaConflict | null => {
|
||||
const data = (err as ApiErrorShape)?.response?.data;
|
||||
const msg = data?.message || '';
|
||||
if (msg.includes('Ya existe una cita')) {
|
||||
return {
|
||||
kind: 'overlap',
|
||||
message: 'El doctor ya tiene una cita en ese horario',
|
||||
conflicting: data?.conflicting_appointment || null,
|
||||
};
|
||||
}
|
||||
const m = /bloqueo \((.+?)\)/.exec(msg);
|
||||
if (m) {
|
||||
return { kind: 'bloqueo', message: `El doctor no puede atender en ese horario: ${m[1]}` };
|
||||
}
|
||||
if (msg.includes('bloqueo')) {
|
||||
return { kind: 'bloqueo', message: 'El doctor no puede atender en ese horario: agenda bloqueada' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Opciones de hora en bloques de 15 min dentro del horario de agenda (8:00-19:00)
|
||||
const QUARTER_TIME_OPTIONS = (() => {
|
||||
const opts = [{ value: '', label: 'Seleccionar hora...' }];
|
||||
@@ -43,7 +101,7 @@ const QUARTER_TIME_OPTIONS = (() => {
|
||||
})();
|
||||
|
||||
const Agenda: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
@@ -60,10 +118,41 @@ const Agenda: FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||
const [stateFilter, setStateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
|
||||
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>('');
|
||||
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas' | 'semana'>('doctores');
|
||||
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||||
if (medicoEmployeeId) return String(medicoEmployeeId);
|
||||
const saved = readAgendaPrefs().doctorFilter;
|
||||
return typeof saved === 'string' ? saved : '';
|
||||
});
|
||||
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>(() => {
|
||||
const saved = readAgendaPrefs().staffTypeFilter;
|
||||
return saved === 'doctor' || saved === 'cosmetologa' ? saved : '';
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<AgendaViewMode>(() => {
|
||||
const saved = readAgendaPrefs().viewMode;
|
||||
return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores';
|
||||
});
|
||||
// Persistencia de vista y filtros (la fecha nunca se guarda: siempre arranca en hoy)
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(AGENDA_PREFS_KEY, JSON.stringify({
|
||||
viewMode,
|
||||
staffTypeFilter,
|
||||
doctorFilter: isMedico ? undefined : doctorFilter,
|
||||
} as AgendaPrefs));
|
||||
} catch {
|
||||
// localStorage no disponible: ignorar
|
||||
}
|
||||
}, [viewMode, staffTypeFilter, doctorFilter, isMedico]);
|
||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>('');
|
||||
// Confirmación telefónica en lote (solo vista Lista)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [bulkConfirming, setBulkConfirming] = useState(false);
|
||||
// Conflicto de agenda (solape / bloqueo) con acción "Ver esa cita"
|
||||
const [conflict, setConflict] = useState<AgendaConflict | null>(null);
|
||||
// Impresión de la agenda del día
|
||||
const [printOpen, setPrintOpen] = useState(false);
|
||||
// Paciente precargado vía query param (?patient_id=X) desde la ficha
|
||||
const [preloadedPatient, setPreloadedPatient] = useState<Patient | null>(null);
|
||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||
@@ -99,6 +188,8 @@ const Agenda: FC = () => {
|
||||
const [crearNuevo, setCrearNuevo] = useState(false);
|
||||
const [nuevoPac, setNuevoPac] = useState({ name: '', phone: '' });
|
||||
const [creandoPac, setCreandoPac] = useState(false);
|
||||
// Aviso de duplicado por teléfono en el mini-form "Crear paciente nuevo"
|
||||
const [nuevoPacDup, setNuevoPacDup] = useState<{ id: number; name: string } | null>(null);
|
||||
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
|
||||
const [quickMode, setQuickMode] = useState(false);
|
||||
const [quickForm, setQuickForm] = useState({
|
||||
@@ -322,9 +413,7 @@ const Agenda: FC = () => {
|
||||
toast.success('Cita movida');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo mover la cita');
|
||||
console.error(err);
|
||||
applyAgendaError(err, 'No se pudo mover la cita');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -498,6 +587,8 @@ const Agenda: FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// La selección en lote no sobrevive cambios de fecha/vista/filtros
|
||||
setSelectedIds([]);
|
||||
// Vista Semana: el API ya soporta date_from/date_to (una sola llamada por rango)
|
||||
const params: Record<string, string> = viewMode === 'semana'
|
||||
? { date_from: weekDatesOf(date)[0], date_to: weekDatesOf(date)[6], page_size: '200' }
|
||||
@@ -535,6 +626,43 @@ const Agenda: FC = () => {
|
||||
loadReferences();
|
||||
}, [load, loadReferences]);
|
||||
|
||||
// Pre-carga "Agendar cita" desde la ficha del paciente: /agenda?patient_id=X
|
||||
// Abre el modal de nueva cita con el paciente ya seleccionado.
|
||||
// Se re-ejecuta cuando cambia patient_id (llegar a /agenda ya montada desde el
|
||||
// drawer no remonta el componente) y el parámetro se consume tras abrir el
|
||||
// modal para poder reabrir la misma ficha más de una vez.
|
||||
const preloadPatientId = searchParams.get('patient_id');
|
||||
useEffect(() => {
|
||||
if (!preloadPatientId) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
|
||||
if (active && res.status === 'success') {
|
||||
setPreloadedPatient(res.patient);
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setCrearNuevo(false);
|
||||
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
|
||||
setCreateOpen(true);
|
||||
}
|
||||
} catch {
|
||||
console.error('No se pudo precargar el paciente');
|
||||
} finally {
|
||||
if (active) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('patient_id');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [preloadPatientId, setSearchParams]);
|
||||
|
||||
// Cargar pacientes para el selector (búsqueda server-side)
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
@@ -549,6 +677,32 @@ const Agenda: FC = () => {
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
// Aviso de duplicado: teléfono del mini-form "Crear paciente nuevo"
|
||||
useEffect(() => {
|
||||
if (!crearNuevo) {
|
||||
setNuevoPacDup(null);
|
||||
return;
|
||||
}
|
||||
const phone = nuevoPac.phone.trim();
|
||||
if (phone.replace(/\D/g, '').length < 7) {
|
||||
setNuevoPacDup(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.checkPatientPhone(phone);
|
||||
if (active) setNuevoPacDup(res.status === 'success' && res.exists && res.patient ? { id: res.patient.id, name: res.patient.name } : null);
|
||||
} catch {
|
||||
if (active) setNuevoPacDup(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [nuevoPac.phone, crearNuevo]);
|
||||
|
||||
// Crear paciente nuevo desde el selector del modal y seleccionarlo
|
||||
const crearYSeleccionarPaciente = async () => {
|
||||
if (!nuevoPac.name.trim() || !nuevoPac.phone.trim()) {
|
||||
@@ -644,10 +798,8 @@ const Agenda: FC = () => {
|
||||
setDetailApt(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
// Mostrar el rechazo del servidor (horario 8-19h, solapamiento por médico, bloqueos)
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al actualizar la reserva');
|
||||
console.error(err);
|
||||
// Mostrar el rechazo del servidor con mensaje humano si es conflicto
|
||||
applyAgendaError(err, 'Error al actualizar la reserva');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
@@ -673,6 +825,65 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Confirmación telefónica en lote (solo vista Lista) ----
|
||||
// Solo citas pendientes/confirmadas sin 1ra confirmación telefónica.
|
||||
const bulkEligible = (apt: Appointment): boolean =>
|
||||
['pending', 'confirmed'].includes(apt.state) && !apt.phone_confirmed;
|
||||
|
||||
const toggleSelected = (id: number) => {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const bulkConfirmSelected = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
try {
|
||||
setBulkConfirming(true);
|
||||
const res = await odooApi.bulkPhoneConfirm(selectedIds, true);
|
||||
const n = res.confirmed_count ?? 0;
|
||||
const omitidas = (res.skipped_invalid_state?.length || 0) + (res.not_found?.length || 0);
|
||||
toast.success(`${n} ${n === 1 ? 'cita confirmada' : 'citas confirmadas'}${omitidas ? ` · ${omitidas} omitidas` : ''}`);
|
||||
setSelectedIds([]);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error en la confirmación en lote');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBulkConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Conflicto de agenda: mensaje humano + "Ver esa cita" ----
|
||||
const applyAgendaError = (err: unknown, fallback: string) => {
|
||||
const c = parseAgendaConflict(err);
|
||||
if (c) {
|
||||
setConflict(c);
|
||||
toast.error(c.message);
|
||||
return;
|
||||
}
|
||||
const msg = (err as ApiErrorShape)?.response?.data?.message;
|
||||
toast.error(msg || fallback);
|
||||
console.error(err);
|
||||
};
|
||||
|
||||
const verCitaEnConflicto = async () => {
|
||||
if (!conflict?.conflicting) return;
|
||||
try {
|
||||
const res = await odooApi.getAppointment(conflict.conflicting.id);
|
||||
setConflict(null);
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
setCrearNuevo(false);
|
||||
if (res.status === 'success') {
|
||||
openAptDetail(res.appointment);
|
||||
} else {
|
||||
toast.error('No se pudo abrir la cita en conflicto');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('No se pudo abrir la cita en conflicto');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||
try {
|
||||
setPkgUpdating(id);
|
||||
@@ -756,7 +967,7 @@ const Agenda: FC = () => {
|
||||
service_id: parseInt(form.service_id, 10),
|
||||
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
time: parseTimeToFloat(form.time) as unknown as string,
|
||||
notes: form.notes,
|
||||
sessions,
|
||||
});
|
||||
@@ -773,8 +984,7 @@ const Agenda: FC = () => {
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear cita');
|
||||
console.error(err);
|
||||
applyAgendaError(err, 'Error al crear cita');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -822,9 +1032,7 @@ const Agenda: FC = () => {
|
||||
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);
|
||||
applyAgendaError(err, 'No se pudo actualizar la cita');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -907,9 +1115,7 @@ const Agenda: FC = () => {
|
||||
setCreateOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al crear la cita rápida');
|
||||
console.error(err);
|
||||
applyAgendaError(err, 'Error al crear la cita rápida');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -921,17 +1127,25 @@ const Agenda: FC = () => {
|
||||
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
|
||||
// Garantiza que el paciente precargado (desde celda, query param o edición) aparezca
|
||||
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
|
||||
const pre = preloadedPatient && String(preloadedPatient.id) === form.patient_id ? preloadedPatient : null;
|
||||
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({
|
||||
value: form.patient_id,
|
||||
label: pre
|
||||
? `${pre.name} — ${pre.phone || 'sin teléfono'}`
|
||||
: 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]);
|
||||
}, [patients, patientSearch, form.patient_id, appointments, preloadedPatient]);
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Seleccionar servicio...' },
|
||||
@@ -955,6 +1169,20 @@ const Agenda: FC = () => {
|
||||
const TableHeader = () => (
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-center p-3 text-xs font-medium text-theme-muted uppercase" title="Seleccionar citas por confirmar">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Seleccionar todas las citas por confirmar"
|
||||
checked={
|
||||
visibleAppointments.some(bulkEligible) &&
|
||||
visibleAppointments.filter(bulkEligible).every((a) => selectedIds.includes(a.id))
|
||||
}
|
||||
onChange={(e) => {
|
||||
setSelectedIds(e.target.checked ? visibleAppointments.filter(bulkEligible).map((a) => a.id) : []);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-theme-border-strong"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
|
||||
@@ -1107,6 +1335,16 @@ const Agenda: FC = () => {
|
||||
>
|
||||
Hoy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mb-[1px]"
|
||||
onClick={() => setPrintOpen(true)}
|
||||
title="Imprimir agenda del día"
|
||||
>
|
||||
<Printer size={16} className="mr-1" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
label="Estado"
|
||||
@@ -1668,6 +1906,29 @@ const Agenda: FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Barra de confirmación telefónica en lote (solo vista Lista) */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3 p-3 rounded-xl border border-blue-200 bg-blue-50">
|
||||
<PhoneCall size={16} className="text-blue-700 shrink-0" />
|
||||
<p className="text-sm font-medium text-theme-heading">
|
||||
{selectedIds.length} {selectedIds.length === 1 ? 'cita seleccionada' : 'citas seleccionadas'}
|
||||
</p>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedIds([])}>
|
||||
Quitar selección
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={bulkConfirmSelected}
|
||||
loading={bulkConfirming}
|
||||
title="Marcar la 1ra confirmación (teléfono) de las citas seleccionadas"
|
||||
>
|
||||
<PhoneCall size={14} className="mr-1.5" />
|
||||
Confirmar seleccionadas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -1675,6 +1936,17 @@ const Agenda: FC = () => {
|
||||
<tbody className="divide-y">
|
||||
{visibleAppointments.map((apt) => (
|
||||
<tr key={apt.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-center">
|
||||
{bulkEligible(apt) ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(apt.id)}
|
||||
onChange={() => toggleSelected(apt.id)}
|
||||
aria-label={`Seleccionar cita de ${apt.patient} ${apt.time}`}
|
||||
className="w-4 h-4 rounded border-theme-border-strong"
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-heading">
|
||||
<Clock size={14} className="inline mr-1 text-theme-muted" />
|
||||
{apt.time}
|
||||
@@ -1734,7 +2006,22 @@ const Agenda: FC = () => {
|
||||
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
actions={<ActionButtons apt={apt} />}
|
||||
actions={
|
||||
<>
|
||||
{bulkEligible(apt) && (
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-theme-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(apt.id)}
|
||||
onChange={() => toggleSelected(apt.id)}
|
||||
className="w-4 h-4 rounded border-theme-border-strong"
|
||||
/>
|
||||
Sel.
|
||||
</label>
|
||||
)}
|
||||
<ActionButtons apt={apt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1948,6 +2235,21 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{nuevoPacDup && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<p className="font-medium">
|
||||
Este teléfono ya está registrado: {nuevoPacDup.name}. ¿Es la misma persona?
|
||||
</p>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => openPatient(nuevoPacDup.id)}>
|
||||
Abrir su ficha
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNuevoPacDup(null)}>
|
||||
Continuar de todos modos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
|
||||
Crear y seleccionar
|
||||
@@ -2298,6 +2600,58 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */}
|
||||
<Modal
|
||||
isOpen={!!conflict}
|
||||
onClose={() => setConflict(null)}
|
||||
title="Conflicto de horario"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setConflict(null)}>Entendido</Button>
|
||||
{conflict?.conflicting && (
|
||||
<Button onClick={verCitaEnConflicto}>
|
||||
<CalendarClock size={16} className="mr-1.5" />
|
||||
Ver esa cita
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle size={22} className="text-amber-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading">{conflict?.message}</p>
|
||||
{conflict?.conflicting && (
|
||||
<p className="mt-2 text-sm text-theme-muted">
|
||||
Cita existente: <span className="font-medium text-theme-heading">{conflict.conflicting.time} · {conflict.conflicting.patient}</span>
|
||||
{conflict.conflicting.doctor ? ` · ${conflict.conflicting.doctor}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{conflict?.kind === 'overlap' && !conflict.conflicting && (
|
||||
<p className="mt-2 text-xs text-theme-muted">Ajusta la hora o elige otro médico.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Vista de impresión de la agenda del día (bloque por doctor) */}
|
||||
<AgendaPrint
|
||||
isOpen={printOpen}
|
||||
onClose={() => setPrintOpen(false)}
|
||||
date={date}
|
||||
dateLabel={new Date(`${date}T12:00:00`).toLocaleDateString('es-MX', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
appointments={appointments}
|
||||
bloqueos={bloqueos}
|
||||
doctors={typedDoctors}
|
||||
filterDoctorName={doctorFilter ? (doctors.find((d) => String(d.id) === doctorFilter)?.name || null) : null}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -142,6 +143,9 @@ const Pacientes: FC = () => {
|
||||
const [editing, setEditing] = useState<Patient | null>(null);
|
||||
const [photo, setPhoto] = useState<string | null>(null);
|
||||
const photoRef = useRef<HTMLInputElement>(null);
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Aviso de duplicado por teléfono/móvil en el formulario de alta/edición
|
||||
const [phoneDup, setPhoneDup] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
const emptyForm: PatientForm = {
|
||||
name: '',
|
||||
@@ -290,6 +294,36 @@ const Pacientes: FC = () => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
// Duplicado de teléfono: se consulta con debounce al escribir (phone o mobile).
|
||||
// Al editar se excluye el propio paciente para no auto-alertarse.
|
||||
useEffect(() => {
|
||||
if (!modalOpen) {
|
||||
setPhoneDup(null);
|
||||
return;
|
||||
}
|
||||
const digitsOf = (s: string) => (s || '').replace(/\D/g, '');
|
||||
const phone = (form.phone || '').trim();
|
||||
const mobile = (form.mobile || '').trim();
|
||||
const checkVal = digitsOf(phone).length >= 7 ? phone : mobile;
|
||||
if (digitsOf(checkVal).length < 7) {
|
||||
setPhoneDup(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.checkPatientPhone(checkVal, editing?.id);
|
||||
if (active) setPhoneDup(res.status === 'success' && res.exists && res.patient ? { id: res.patient.id, name: res.patient.name } : null);
|
||||
} catch {
|
||||
if (active) setPhoneDup(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [form.phone, form.mobile, editing?.id, modalOpen]);
|
||||
|
||||
const onPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
@@ -638,6 +672,21 @@ const Pacientes: FC = () => {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Nombre *" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} />
|
||||
<Input label="Teléfono *" value={form.phone} onChange={(e) => updateForm({ phone: e.target.value })} />
|
||||
{phoneDup && (
|
||||
<div className="sm:col-span-2 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-800">
|
||||
<p className="font-medium">
|
||||
Este teléfono ya está registrado: {phoneDup.name}. ¿Es la misma persona?
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openPatient(phoneDup.id)}>
|
||||
Abrir su ficha
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPhoneDup(null)}>
|
||||
Continuar de todos modos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input label="Email" type="email" value={form.email} onChange={(e) => updateForm({ email: e.target.value })} />
|
||||
<Input label="Celular" value={form.mobile} onChange={(e) => updateForm({ mobile: e.target.value })} />
|
||||
<Input label="Teléfono casa" value={form.home_phone} onChange={(e) => updateForm({ home_phone: e.target.value })} />
|
||||
|
||||
@@ -47,7 +47,7 @@ interface TicketLine {
|
||||
|
||||
const Pos: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Catálogo
|
||||
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -142,14 +142,16 @@ const Pos: FC = () => {
|
||||
|
||||
// Pre-carga "Cobrar a la cita" desde Agenda:
|
||||
// /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N
|
||||
// Se re-ejecuta cuando cambia patient_id (llegar a /pos ya montado desde el
|
||||
// drawer no remonta el componente) y los params se consumen tras precargar.
|
||||
const preloadPatientId = searchParams.get('patient_id');
|
||||
useEffect(() => {
|
||||
const pid = searchParams.get('patient_id');
|
||||
if (!pid) return;
|
||||
if (!preloadPatientId) return;
|
||||
const sid = searchParams.get('service_id');
|
||||
const did = searchParams.get('doctor_id');
|
||||
(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatient(parseInt(pid, 10));
|
||||
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
|
||||
if (res.status === 'success') selectPaciente(res.patient);
|
||||
} catch {
|
||||
toast.error('No se pudo cargar el paciente de la cita');
|
||||
@@ -173,10 +175,13 @@ const Pos: FC = () => {
|
||||
}
|
||||
}
|
||||
toast.success('Cita precargada en el ticket');
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
['patient_id', 'service_id', 'doctor_id', 'appointment_id'].forEach((k) => next.delete(k));
|
||||
return next;
|
||||
}, { replace: true });
|
||||
})();
|
||||
// Solo al montar: los query params son la fuente inicial
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [preloadPatientId, searchParams, setSearchParams]);
|
||||
|
||||
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user