Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, Ban, Trash2 } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
toast,
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
@@ -28,6 +29,7 @@ const stateOptions = [
|
||||
];
|
||||
|
||||
const Agenda: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -36,12 +38,24 @@ const Agenda: FC = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updating, setUpdating] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
|
||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||
const [stateFilter, setStateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
|
||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
|
||||
const [bloqueosOpen, setBloqueosOpen] = useState(false);
|
||||
const [bloqueoForm, setBloqueoForm] = useState({ doctor_id: '', date: '', all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
|
||||
const [savingBloqueo, setSavingBloqueo] = useState(false);
|
||||
const [dragApt, setDragApt] = useState<Appointment | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
patient_id: '',
|
||||
@@ -73,13 +87,212 @@ const Agenda: FC = () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---- Filtros rápidos ----
|
||||
const timeToFloat = (t: string): number | null => {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec((t || '').trim());
|
||||
return m ? parseInt(m[1], 10) + parseInt(m[2], 10) / 60 : null;
|
||||
};
|
||||
const nowDate = new Date();
|
||||
const nowFloat = nowDate.getHours() + nowDate.getMinutes() / 60;
|
||||
const todayStr = `${nowDate.getFullYear()}-${String(nowDate.getMonth() + 1).padStart(2, '0')}-${String(nowDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Candidata a check-in: confirmada, de hoy, ya empezó y aún no termina
|
||||
const isCheckinDue = (a: Appointment): boolean => {
|
||||
const start = timeToFloat(a.time);
|
||||
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
|
||||
const end = start + (a.duration ?? 30) / 60;
|
||||
return start <= nowFloat && nowFloat < end;
|
||||
};
|
||||
// Candidata a no-show: confirmada, de hoy, ya debió terminar
|
||||
const isNoShowDue = (a: Appointment): boolean => {
|
||||
const start = timeToFloat(a.time);
|
||||
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
|
||||
return start + (a.duration ?? 30) / 60 <= nowFloat;
|
||||
};
|
||||
|
||||
const visibleAppointments = filteredAppointments.filter((apt) => {
|
||||
if (quickFilter === 'primera' && !apt.is_first_visit) return false;
|
||||
if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false;
|
||||
if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length;
|
||||
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
|
||||
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
|
||||
|
||||
// ---- Vista por médico (grid doctores x horas) ----
|
||||
const GRID_BASE_START = 9; // horario clínica: 9:00
|
||||
const GRID_BASE_END = 18; // horario clínica: 18:00
|
||||
|
||||
const aptHour = (t: string): number | null => {
|
||||
const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim());
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10);
|
||||
return Number.isNaN(h) ? null : h;
|
||||
};
|
||||
|
||||
// Las canceladas no ocupan horario: el hueco queda libre
|
||||
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
|
||||
|
||||
const bookedHours = gridAppointments
|
||||
.map((a) => aptHour(a.time))
|
||||
.filter((h): h is number => h !== null);
|
||||
const gridStart = bookedHours.length ? Math.min(GRID_BASE_START, ...bookedHours) : GRID_BASE_START;
|
||||
const gridEnd = bookedHours.length ? Math.max(GRID_BASE_END, ...bookedHours) : GRID_BASE_END;
|
||||
const gridHours = Array.from({ length: gridEnd - gridStart + 1 }, (_, i) => gridStart + i);
|
||||
|
||||
const gridDoctors = doctorFilter
|
||||
? doctors.filter((d) => String(d.id) === doctorFilter)
|
||||
: doctors;
|
||||
const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id);
|
||||
const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [
|
||||
...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })),
|
||||
...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []),
|
||||
];
|
||||
|
||||
const gridCellAppointments = (doctorId: number | null, hour: number) =>
|
||||
gridAppointments.filter((a) =>
|
||||
(doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptHour(a.time) === hour);
|
||||
|
||||
const gridDoctorCount = (doctorId: number | null) =>
|
||||
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
|
||||
|
||||
// Bloqueo que cubre una celda (médico × hora)
|
||||
const bloqueoEnCelda = (doctorId: number | null, hour: number) =>
|
||||
doctorId
|
||||
? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (hour >= b.time_from && hour < b.time_to)))
|
||||
: undefined;
|
||||
|
||||
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||
const handleDrop = async (doctorId: number | null, hour: number) => {
|
||||
if (!dragApt) return;
|
||||
const apt = dragApt;
|
||||
setDragApt(null);
|
||||
setDropTarget(null);
|
||||
if ((apt.doctor_id ?? null) === doctorId && aptHour(apt.time) === hour) return;
|
||||
const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar';
|
||||
const horaStr = `${String(hour).padStart(2, '0')}:00`;
|
||||
if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return;
|
||||
try {
|
||||
await odooApi.updateAppointment(apt.id, {
|
||||
doctor_id: doctorId ?? null,
|
||||
time: hour as unknown as string,
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const chipDragProps = (apt: Appointment) =>
|
||||
DRAGGABLE_STATES.includes(apt.state)
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setDragApt(apt); },
|
||||
onDragEnd: () => { setDragApt(null); setDropTarget(null); },
|
||||
}
|
||||
: {};
|
||||
|
||||
const openBloqueos = () => {
|
||||
setBloqueoForm({ doctor_id: doctorFilter, date, all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
|
||||
setBloqueosOpen(true);
|
||||
};
|
||||
|
||||
const saveBloqueo = async () => {
|
||||
if (!bloqueoForm.doctor_id || !bloqueoForm.date) {
|
||||
toast.error('Médico y fecha son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSavingBloqueo(true);
|
||||
await odooApi.createBloqueo({
|
||||
doctor_id: parseInt(bloqueoForm.doctor_id, 10),
|
||||
date: bloqueoForm.date,
|
||||
all_day: bloqueoForm.all_day,
|
||||
time_from: parseTimeToFloat(bloqueoForm.time_from) ?? 9,
|
||||
time_to: parseTimeToFloat(bloqueoForm.time_to) ?? 10,
|
||||
motivo: bloqueoForm.motivo,
|
||||
});
|
||||
toast.success('Bloqueo creado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al crear bloqueo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingBloqueo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBloqueo = async (id: number) => {
|
||||
try {
|
||||
await odooApi.deleteBloqueo(id);
|
||||
toast.success('Bloqueo eliminado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al eliminar bloqueo');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const horaFloatStr = (h: number) => {
|
||||
const hh = Math.floor(h);
|
||||
const mm = Math.round((h - hh) * 60);
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const initials = (name: string) =>
|
||||
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
||||
|
||||
// Colores por estado siguiendo la paleta de la marca (coral/mint/sky/lilac)
|
||||
const gridStateClass: Record<string, string> = {
|
||||
pending: 'border-brand-coral bg-theme-accent-bg text-theme-heading',
|
||||
confirmed: 'border-emerald-700 bg-brand-mint text-theme-heading',
|
||||
arrived: 'border-sky-700 bg-brand-sky text-theme-heading',
|
||||
in_progress: 'border-violet-600 bg-brand-lilac text-theme-heading',
|
||||
done: 'border-theme-border-strong bg-theme-bg text-theme-muted',
|
||||
no_show: 'border-rose-400 bg-rose-50 text-rose-700',
|
||||
};
|
||||
|
||||
const gridStateLegend: { state: string; label: string; dot: string }[] = [
|
||||
{ state: 'pending', label: 'Pendiente', dot: 'bg-brand-coral' },
|
||||
{ state: 'confirmed', label: 'Confirmada', dot: 'bg-brand-mint border border-emerald-700' },
|
||||
{ state: 'arrived', label: 'Llegó', dot: 'bg-brand-sky border border-sky-700' },
|
||||
{ state: 'in_progress', label: 'En curso', dot: 'bg-brand-lilac border border-violet-600' },
|
||||
{ state: 'done', label: 'Completada', dot: 'bg-theme-border-strong' },
|
||||
];
|
||||
|
||||
const firstName = (full: string) => (full || '').trim().split(' ')[0] || '-';
|
||||
|
||||
const gridLegend = (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{gridStateLegend.map((item) => (
|
||||
<span key={item.state} className="inline-flex items-center gap-1.5 text-xs text-theme-muted">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${item.dot}`} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs text-theme-muted">
|
||||
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: Record<string, string> = date ? { date } : {};
|
||||
const res = await odooApi.getAppointments(params);
|
||||
if (res.status === 'success') setAppointments(res.appointments);
|
||||
if (res.status === 'success') {
|
||||
setAppointments(res.appointments);
|
||||
setBloqueos(res.bloqueos || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar citas');
|
||||
toast.error('Error al cargar citas');
|
||||
@@ -147,6 +360,7 @@ const Agenda: FC = () => {
|
||||
setUpdating(id);
|
||||
await odooApi.updateAppointmentStatus(id, action);
|
||||
toast.success('Estado actualizado');
|
||||
setDetailApt(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar estado');
|
||||
@@ -156,6 +370,47 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openAptDetail = (apt: Appointment) => {
|
||||
setDetailApt(apt);
|
||||
setEditMode(false);
|
||||
setEditForm({
|
||||
date: apt.date,
|
||||
time: apt.time,
|
||||
doctor_id: apt.doctor_id ? String(apt.doctor_id) : '',
|
||||
notes: apt.notes || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveAptEdit = async () => {
|
||||
if (!detailApt) return;
|
||||
if (!editForm.date || !editForm.time) {
|
||||
toast.error('Fecha y hora son obligatorias');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
await odooApi.updateAppointment(detailApt.id, {
|
||||
date: editForm.date,
|
||||
time: parseTimeToFloat(editForm.time) as unknown as string,
|
||||
doctor_id: editForm.doctor_id ? parseInt(editForm.doctor_id, 10) : undefined,
|
||||
notes: editForm.notes,
|
||||
});
|
||||
toast.success('Reserva actualizada');
|
||||
setDetailApt(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar la reserva');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const aptStateLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
|
||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||
try {
|
||||
setPkgUpdating(id);
|
||||
@@ -341,6 +596,17 @@ const Agenda: FC = () => {
|
||||
<CheckCheck size={16} className="text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{(apt.state === 'confirmed' || apt.state === 'arrived') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'no_show')}
|
||||
disabled={updating === apt.id}
|
||||
title="No se presentó (no show)"
|
||||
>
|
||||
<UserX size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||
apt.package_finished ? (
|
||||
<Button
|
||||
@@ -381,6 +647,10 @@ const Agenda: FC = () => {
|
||||
return (
|
||||
<Layout title="Agenda" subtitle="Gestión de citas">
|
||||
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
|
||||
<Button variant="outline" onClick={openBloqueos}>
|
||||
<Ban size={16} className="mr-2" />
|
||||
Bloqueos
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setExpressOpen(true)}>
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Valoración express
|
||||
@@ -415,13 +685,287 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setDoctorFilter(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
<div className="flex gap-1 sm:ml-auto">
|
||||
<Button
|
||||
variant={viewMode === 'lista' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('lista')}
|
||||
title="Vista de lista"
|
||||
>
|
||||
<List size={16} className="mr-1" />
|
||||
Lista
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'doctores' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('doctores')}
|
||||
title="Médicos en columnas, horas en filas"
|
||||
>
|
||||
<LayoutGrid size={16} className="mr-1" />
|
||||
Por médico
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'horas' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('horas')}
|
||||
title="Médicos en filas, horas en columnas"
|
||||
>
|
||||
<Rows3 size={16} className="mr-1" />
|
||||
Por hora
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros rápidos */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||
{viewMode !== 'lista' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickFilter(quickFilter === 'libres' ? '' : 'libres')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
quickFilter === 'libres'
|
||||
? 'bg-brand-mint text-theme-heading border-emerald-700'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
Espacios libres
|
||||
</button>
|
||||
)}
|
||||
{([
|
||||
{ key: 'primera', label: 'Primera vez', count: countPrimera },
|
||||
{ key: 'checkin', label: 'Check-in', count: countCheckin },
|
||||
{ key: 'noshow', label: 'No show', count: countNoShow },
|
||||
] as const).map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => setQuickFilter(quickFilter === chip.key ? '' : chip.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
quickFilter === chip.key
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
<span className={`ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
|
||||
quickFilter === chip.key ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
|
||||
}`}>
|
||||
{chip.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{quickFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickFilter('')}
|
||||
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||
>
|
||||
Quitar filtro
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
) : viewMode === 'doctores' ? (
|
||||
gridColumns.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin médicos"
|
||||
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
|
||||
<table className="w-full border-collapse bg-theme-surface">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Hora
|
||||
</th>
|
||||
{gridColumns.map((col) => (
|
||||
<th key={col.key} className="min-w-[180px] p-3 border-b border-l border-theme-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
|
||||
{initials(col.label)}
|
||||
</span>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate text-sm font-semibold text-theme-heading normal-case">
|
||||
{col.label}
|
||||
</p>
|
||||
<p className="truncate text-[11px] font-normal text-theme-muted normal-case">
|
||||
{col.subtitle ? `${col.subtitle} · ` : ''}
|
||||
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gridHours.map((hour) => (
|
||||
<tr key={hour}>
|
||||
<td className="sticky left-0 z-10 bg-theme-surface p-3 text-xs font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
|
||||
{`${String(hour).padStart(2, '0')}:00`}
|
||||
</td>
|
||||
{gridColumns.map((col) => {
|
||||
const cell = gridCellAppointments(col.doctorId, hour);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
||||
const cellKey = `${col.key}-${hour}`;
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
||||
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
>
|
||||
{bloqueo ? (
|
||||
<div
|
||||
className="rounded-xl border border-theme-border py-3.5 text-center text-[11px] font-medium text-theme-muted"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
|
||||
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
|
||||
>
|
||||
<Ban size={12} className="inline mr-1" />
|
||||
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
|
||||
</div>
|
||||
) : cell.length === 0 ? (
|
||||
<div className={`rounded-xl border py-3.5 text-center text-[11px] font-medium transition-colors ${
|
||||
quickFilter === 'libres'
|
||||
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
|
||||
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
|
||||
}`}>
|
||||
Libre
|
||||
</div>
|
||||
) : (
|
||||
cell.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
onClick={() => openAptDetail(apt)}
|
||||
{...chipDragProps(apt)}
|
||||
className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs shadow-card transition-shadow hover:shadow-soft ${
|
||||
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
>
|
||||
<div className="font-semibold truncate">
|
||||
{apt.time} · {apt.patient}
|
||||
</div>
|
||||
<div className="truncate opacity-75">{apt.service}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{gridLegend}
|
||||
</div>
|
||||
)
|
||||
) : viewMode === 'horas' ? (
|
||||
gridColumns.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin médicos"
|
||||
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
|
||||
<table className="w-full border-collapse bg-theme-surface">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Médico
|
||||
</th>
|
||||
{gridHours.map((hour) => (
|
||||
<th key={hour} className="min-w-[96px] p-2.5 text-center text-xs font-medium text-theme-muted border-b border-l border-theme-border">
|
||||
{`${String(hour).padStart(2, '0')}:00`}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gridColumns.map((col) => (
|
||||
<tr key={col.key}>
|
||||
<td className="sticky left-0 z-10 bg-theme-surface p-2.5 border-b border-theme-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
|
||||
{initials(col.label)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-theme-heading">
|
||||
{col.label}
|
||||
</p>
|
||||
<p className="text-[10px] text-theme-muted">
|
||||
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{gridHours.map((hour) => {
|
||||
const cell = gridCellAppointments(col.doctorId, hour);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
||||
const cellKey = `${col.key}-${hour}`;
|
||||
return (
|
||||
<td
|
||||
key={hour}
|
||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
||||
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
>
|
||||
{bloqueo ? (
|
||||
<div
|
||||
className="rounded-lg border border-theme-border py-2 text-center text-[10px] font-medium text-theme-muted"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
|
||||
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
|
||||
>
|
||||
<Ban size={11} className="inline" />
|
||||
</div>
|
||||
) : cell.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-lg border py-2 transition-colors ${
|
||||
quickFilter === 'libres'
|
||||
? 'bg-brand-mint border-emerald-700'
|
||||
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
|
||||
}`}
|
||||
title="Libre"
|
||||
/>
|
||||
) : (
|
||||
cell.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
onClick={() => openAptDetail(apt)}
|
||||
{...chipDragProps(apt)}
|
||||
className={`mb-1 rounded-lg border-l-2 px-1.5 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
|
||||
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
>
|
||||
<div className="font-semibold truncate">
|
||||
{apt.time} · {firstName(apt.patient)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{gridLegend}
|
||||
</div>
|
||||
)
|
||||
) : visibleAppointments.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
@@ -435,14 +979,18 @@ const Agenda: FC = () => {
|
||||
<table className="w-full">
|
||||
<TableHeader />
|
||||
<tbody className="divide-y">
|
||||
{filteredAppointments.map((apt) => (
|
||||
{visibleAppointments.map((apt) => (
|
||||
<tr key={apt.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-heading">
|
||||
<Clock size={14} className="inline mr-1 text-theme-muted" />
|
||||
{apt.time}
|
||||
</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.service}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">
|
||||
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
|
||||
{apt.service}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||
@@ -459,7 +1007,7 @@ const Agenda: FC = () => {
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filteredAppointments.map((apt) => (
|
||||
{visibleAppointments.map((apt) => (
|
||||
<MobileCard
|
||||
key={apt.id}
|
||||
title={apt.patient}
|
||||
@@ -479,6 +1027,98 @@ const Agenda: FC = () => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal de bloqueos de agenda */}
|
||||
<Modal
|
||||
isOpen={bloqueosOpen}
|
||||
onClose={() => setBloqueosOpen(false)}
|
||||
title={`Bloqueos de agenda — ${date}`}
|
||||
maxWidth="lg"
|
||||
footer={<Button variant="outline" onClick={() => setBloqueosOpen(false)}>Cerrar</Button>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Alta rápida */}
|
||||
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase">Nuevo bloqueo</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Médico *"
|
||||
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={bloqueoForm.doctor_id}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Fecha *"
|
||||
type="date"
|
||||
value={bloqueoForm.date}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, date: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Motivo"
|
||||
placeholder="Comida, Vacaciones, Junta..."
|
||||
value={bloqueoForm.motivo}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, motivo: e.target.value })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 self-end pb-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bloqueoForm.all_day}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, all_day: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-theme-border-strong"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading">Todo el día</span>
|
||||
</label>
|
||||
{!bloqueoForm.all_day && (
|
||||
<>
|
||||
<Input
|
||||
label="Desde"
|
||||
type="time"
|
||||
value={bloqueoForm.time_from}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_from: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Hasta"
|
||||
type="time"
|
||||
value={bloqueoForm.time_to}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_to: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={saveBloqueo} loading={savingBloqueo}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Crear bloqueo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista del día */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Bloqueos de este día</p>
|
||||
{bloqueos.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-4 text-center">No hay bloqueos en la fecha seleccionada.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||
{bloqueos.map((b) => (
|
||||
<li key={b.id} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">
|
||||
<span className="font-medium">{b.doctor}</span>
|
||||
<span className="text-theme-muted">
|
||||
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`}
|
||||
{b.motivo ? ` · ${b.motivo}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeBloqueo(b.id)} title="Eliminar">
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
@@ -635,6 +1275,124 @@ const Agenda: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal detalle de cita: confirmar / no show / editar reserva */}
|
||||
<Modal
|
||||
isOpen={!!detailApt}
|
||||
onClose={() => setDetailApt(null)}
|
||||
title={editMode ? 'Editar reserva' : 'Detalle de la cita'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
editMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
|
||||
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{detailApt && !editMode && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForAppointmentState(detailApt.state)}>
|
||||
{aptStateLabels[detailApt.state] || detailApt.state}
|
||||
</Badge>
|
||||
{detailApt.is_first_visit && (
|
||||
<Badge variant="primary">Primera vez</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Paciente</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detailApt.patient}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Servicio</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detailApt.service}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Fecha y hora</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detailApt.date} · {detailApt.time}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Médico</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Duración</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detailApt.duration ? `${detailApt.duration} min` : '-'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Precio</p>
|
||||
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
{detailApt.notes && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Notas</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
|
||||
{detailApt.state === 'pending' && (
|
||||
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'confirm')} disabled={updating === detailApt.id}>
|
||||
<CheckCircle size={16} className="mr-1.5" /> Confirmar
|
||||
</Button>
|
||||
)}
|
||||
{detailApt.state === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'arrive')} disabled={updating === detailApt.id}>
|
||||
<UserCheck size={16} className="mr-1.5" /> Llegó
|
||||
</Button>
|
||||
)}
|
||||
{(detailApt.state === 'confirmed' || detailApt.state === 'arrived') && (
|
||||
<Button size="sm" variant="danger" onClick={() => handleStatus(detailApt.id, 'no_show')} disabled={updating === detailApt.id}>
|
||||
<UserX size={16} className="mr-1.5" /> No se presentó
|
||||
</Button>
|
||||
)}
|
||||
{detailApt.state !== 'done' && detailApt.state !== 'cancelled' && detailApt.state !== 'no_show' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleStatus(detailApt.id, 'done')} disabled={updating === detailApt.id}>
|
||||
<CheckCheck size={16} className="mr-1.5" /> Completar
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||
<Edit2 size={16} className="mr-1.5" /> Editar reserva
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detailApt && editMode && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
{detailApt.patient} · {detailApt.service}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
type="date"
|
||||
value={editForm.date}
|
||||
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Hora *"
|
||||
type="time"
|
||||
value={editForm.time}
|
||||
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={editForm.doctor_id}
|
||||
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Notas"
|
||||
value={editForm.notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import UsuariosPanel from '../components/UsuariosPanel';
|
||||
import RecetasPanel from '../components/RecetasPanel';
|
||||
import CatalogoPanel from '../components/CatalogoPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'recetas', label: 'Recetas' },
|
||||
{ key: 'catalogos', label: 'Catálogos' },
|
||||
] as const;
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
{ key: 'procedimientos', label: 'Procedimientos' },
|
||||
] as const;
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
@@ -24,6 +40,13 @@ const defaultSettings: ClinicSettings = {
|
||||
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'clinica';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
||||
const sub = searchParams.get('sub');
|
||||
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
|
||||
});
|
||||
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -89,6 +112,55 @@ const Configuracion: FC = () => {
|
||||
<Layout title="Configuración" subtitle="Ajustes de la clínica">
|
||||
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
||||
|
||||
{/* Tabs de secciones */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'usuarios' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<UsuariosPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'recetas' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<RecetasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'catalogos' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{SUBCATALOGOS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => setSubcatalogo(s.key)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
|
||||
subcatalogo === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
@@ -234,6 +306,7 @@ const Configuracion: FC = () => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
395
frontend/src/pages/Consultas.tsx
Normal file
395
frontend/src/pages/Consultas.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Search, ClipboardPlus } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Visita, type Doctor, type Patient, type Diagnostico } from '../services/odoo';
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const Consultas: FC = () => {
|
||||
const [consultas, setConsultas] = useState<Visita[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [diagnosticos, setDiagnosticos] = useState<Diagnostico[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
const [doctorFiltro, setDoctorFiltro] = useState('');
|
||||
const [busqueda, setBusqueda] = useState('');
|
||||
|
||||
// Modal nueva consulta
|
||||
const [modalNueva, setModalNueva] = useState(false);
|
||||
const [pacienteQuery, setPacienteQuery] = useState('');
|
||||
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
|
||||
const [pacienteSel, setPacienteSel] = useState<Patient | null>(null);
|
||||
const [formNueva, setFormNueva] = useState({
|
||||
doctor_id: '',
|
||||
motivo: '',
|
||||
diag_sel: '',
|
||||
diag_libre: '',
|
||||
tratamiento: '',
|
||||
notas: '',
|
||||
});
|
||||
|
||||
// Modal edición
|
||||
const [selected, setSelected] = useState<Visita | null>(null);
|
||||
const [formEdit, setFormEdit] = useState({ motivo: '', diagnostico: '', tratamiento: '', notas: '', receta_text: '', doctor_id: '' });
|
||||
const [printOpen, setPrintOpen] = useState(false);
|
||||
|
||||
const doctorOptions = [
|
||||
{ value: '', label: 'Sin asignar' },
|
||||
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
||||
];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = { origen: 'consulta', page_size: 100 };
|
||||
params.date = fecha || 'todas';
|
||||
if (doctorFiltro) params.doctor_id = doctorFiltro;
|
||||
if (busqueda) params.search = busqueda;
|
||||
const res = await odooApi.getVisitas(params);
|
||||
if (res.status === 'success') setConsultas(res.visitas);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar consultas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fecha, doctorFiltro, busqueda]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||||
.catch(() => {});
|
||||
odooApi.getDiagnosticos()
|
||||
.then((res) => { if (res.status === 'success') setDiagnosticos(res.items); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Búsqueda server-side de paciente para el modal
|
||||
useEffect(() => {
|
||||
if (!modalNueva || pacienteSel) return;
|
||||
if (pacienteQuery.trim().length < 2) {
|
||||
setPacienteResults([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
|
||||
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [pacienteQuery, modalNueva, pacienteSel]);
|
||||
|
||||
const openNueva = () => {
|
||||
setPacienteSel(null);
|
||||
setPacienteQuery('');
|
||||
setPacienteResults([]);
|
||||
setFormNueva({ doctor_id: '', motivo: '', diag_sel: '', diag_libre: '', tratamiento: '', notas: '' });
|
||||
setModalNueva(true);
|
||||
};
|
||||
|
||||
const guardarNueva = async () => {
|
||||
if (!pacienteSel) {
|
||||
toast.error('Selecciona un paciente');
|
||||
return;
|
||||
}
|
||||
const diagnostico = formNueva.diag_sel
|
||||
? (diagnosticos.find((d) => String(d.id) === formNueva.diag_sel)?.name || '')
|
||||
: formNueva.diag_libre.trim();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.createConsulta({
|
||||
partner_id: pacienteSel.id,
|
||||
doctor_id: formNueva.doctor_id ? parseInt(formNueva.doctor_id, 10) : null,
|
||||
motivo: formNueva.motivo,
|
||||
diagnostico,
|
||||
tratamiento: formNueva.tratamiento,
|
||||
notas: formNueva.notas,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
toast.success('Consulta registrada');
|
||||
setModalNueva(false);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al registrar la consulta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (v: Visita) => {
|
||||
setSelected(v);
|
||||
setFormEdit({
|
||||
motivo: v.motivo || '',
|
||||
diagnostico: v.diagnostico || '',
|
||||
tratamiento: v.tratamiento || '',
|
||||
notas: v.notas || '',
|
||||
receta_text: v.receta_text || '',
|
||||
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
|
||||
});
|
||||
};
|
||||
|
||||
const guardarEdicion = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.updateVisita(selected.id, {
|
||||
motivo: formEdit.motivo,
|
||||
diagnostico: formEdit.diagnostico,
|
||||
tratamiento: formEdit.tratamiento,
|
||||
notas: formEdit.notas,
|
||||
receta_text: formEdit.receta_text,
|
||||
doctor_id: formEdit.doctor_id ? parseInt(formEdit.doctor_id, 10) : null,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
toast.success('Consulta actualizada');
|
||||
setSelected(null);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar la consulta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Consultas Médicas" subtitle="Registro directo de consultas">
|
||||
<PageHeader title="Consultas Médicas" subtitle="Consultas registradas sin cita previa">
|
||||
<Button onClick={openNueva}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva consulta
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Input label="Fecha" type="date" value={fecha === 'todas' ? '' : fecha} onChange={(e) => setFecha(e.target.value || 'todas')} />
|
||||
</div>
|
||||
<Button
|
||||
variant={fecha === 'todas' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFecha(fecha === 'todas' ? hoy() : 'todas')}
|
||||
className="mb-1"
|
||||
>
|
||||
Todas
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
|
||||
value={doctorFiltro}
|
||||
onChange={(e) => setDoctorFiltro(e.target.value)}
|
||||
/>
|
||||
<Input label="Paciente" placeholder="Buscar por nombre..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : consultas.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin consultas"
|
||||
subtitle="No hay consultas registradas con los filtros seleccionados."
|
||||
actionLabel="Nueva consulta"
|
||||
onAction={openNueva}
|
||||
icon={<ClipboardPlus size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</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">Motivo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Diagnóstico</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Médico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{consultas.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openEdit(c)}>
|
||||
<td className="p-3 text-sm text-theme-muted whitespace-nowrap">{c.date_start || '-'}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{c.patient}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{c.motivo || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
<span className="block max-w-[220px] truncate">{c.diagnostico || '-'}</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{c.doctor || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal nueva consulta */}
|
||||
<Modal
|
||||
isOpen={modalNueva}
|
||||
onClose={() => setModalNueva(false)}
|
||||
title="Nueva consulta"
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalNueva(false)}>Cancelar</Button>
|
||||
<Button onClick={guardarNueva} loading={submitting} disabled={!pacienteSel}>Guardar consulta</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||
{/* Paciente */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-1.5">Paciente *</p>
|
||||
{pacienteSel ? (
|
||||
<div className="flex items-center justify-between p-3 bg-theme-bg rounded-xl">
|
||||
<span className="text-sm font-medium text-theme-heading">{pacienteSel.name} <span className="text-theme-muted">· {pacienteSel.phone}</span></span>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setPacienteSel(null); setPacienteQuery(''); }}>
|
||||
Cambiar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={pacienteQuery}
|
||||
onChange={(e) => setPacienteQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
{pacienteResults.length > 0 && (
|
||||
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
|
||||
{pacienteResults.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { setPacienteSel(p); setPacienteResults([]); }}
|
||||
className="w-full text-left px-3 py-2.5 text-sm hover:bg-theme-bg transition"
|
||||
>
|
||||
<span className="font-medium text-theme-heading">{p.name}</span>
|
||||
<span className="text-theme-muted"> · {p.phone}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Médico / Atendió"
|
||||
options={doctorOptions}
|
||||
value={formNueva.doctor_id}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, doctor_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Motivo de consulta"
|
||||
value={formNueva.motivo}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, motivo: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Diagnóstico (catálogo)"
|
||||
options={[
|
||||
{ value: '', label: 'Texto libre / sin diagnóstico' },
|
||||
...diagnosticos.map((d) => ({ value: String(d.id), label: d.name })),
|
||||
]}
|
||||
value={formNueva.diag_sel}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, diag_sel: e.target.value })}
|
||||
/>
|
||||
{!formNueva.diag_sel && (
|
||||
<TextArea
|
||||
label="Diagnóstico (texto libre)"
|
||||
value={formNueva.diag_libre}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, diag_libre: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<TextArea
|
||||
label="Tratamiento"
|
||||
value={formNueva.tratamiento}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, tratamiento: e.target.value })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Observaciones"
|
||||
value={formNueva.notas}
|
||||
onChange={(e) => setFormNueva({ ...formNueva, notas: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal edición */}
|
||||
<Modal
|
||||
isOpen={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
title={selected ? `Consulta — ${selected.patient}` : 'Consulta'}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
|
||||
<Button onClick={guardarEdicion} loading={submitting}>Guardar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selected && (
|
||||
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="success">Completada</Badge>
|
||||
<span className="text-sm text-theme-muted">{selected.date_start || '-'}</span>
|
||||
</div>
|
||||
<Select
|
||||
label="Médico / Atendió"
|
||||
options={doctorOptions}
|
||||
value={formEdit.doctor_id}
|
||||
onChange={(e) => setFormEdit({ ...formEdit, doctor_id: e.target.value })}
|
||||
/>
|
||||
<Input label="Motivo" value={formEdit.motivo} onChange={(e) => setFormEdit({ ...formEdit, motivo: e.target.value })} />
|
||||
<TextArea label="Diagnóstico" value={formEdit.diagnostico} onChange={(e) => setFormEdit({ ...formEdit, diagnostico: e.target.value })} />
|
||||
<TextArea label="Tratamiento" value={formEdit.tratamiento} onChange={(e) => setFormEdit({ ...formEdit, tratamiento: e.target.value })} />
|
||||
<TextArea label="Observaciones" value={formEdit.notas} onChange={(e) => setFormEdit({ ...formEdit, notas: e.target.value })} />
|
||||
<RecetaSection
|
||||
value={formEdit.receta_text}
|
||||
onChange={(v) => setFormEdit({ ...formEdit, receta_text: v })}
|
||||
onPrint={() => setPrintOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Vista de impresión de la receta */}
|
||||
{selected && (
|
||||
<RecetaPrint
|
||||
isOpen={printOpen}
|
||||
onClose={() => setPrintOpen(false)}
|
||||
paciente={selected.patient}
|
||||
doctor={selected.doctor}
|
||||
contenido={formEdit.receta_text}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Consultas;
|
||||
@@ -5,7 +5,7 @@ import Layout from '../components/Layout';
|
||||
import { Card, PageHeader, Button, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Birthday } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
type Period = 'today' | 'week' | 'month';
|
||||
|
||||
@@ -50,18 +50,35 @@ const Cumpleanos: FC = () => {
|
||||
};
|
||||
}, [period]);
|
||||
|
||||
const exportBirthdays = () => {
|
||||
const rows: (string | number | boolean | null | undefined)[][] = [
|
||||
['Nombre', 'Teléfono', 'Fecha nacimiento', 'Cumple el', 'Cumple años', 'Última visita', 'Total gastado', 'Adeudo', 'VIP'],
|
||||
];
|
||||
items.forEach((b) =>
|
||||
rows.push([
|
||||
b.name, b.phone, b.birth_date, b.occurs_on, b.turning_age, b.last_visit,
|
||||
b.total_spent, b.amount_due, b.is_vip ? 'Sí' : 'No',
|
||||
])
|
||||
);
|
||||
downloadCsv(`cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||
toast.success(`CSV descargado (${items.length} cumpleañeros)`);
|
||||
const exportBirthdays = async () => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Cumpleañeros',
|
||||
title: 'Cumpleañeros SKEEN',
|
||||
subtitle: `Periodo: ${period} · ${items.length} pacientes`,
|
||||
columns: [
|
||||
{ header: 'Nombre', key: 'name' },
|
||||
{ header: 'Teléfono', key: 'phone' },
|
||||
{ header: 'Fecha nacimiento', key: 'birth_date', format: 'date' },
|
||||
{ header: 'Cumple el', key: 'occurs_on', format: 'date' },
|
||||
{ header: 'Cumple años', key: 'turning_age', format: 'number' },
|
||||
{ header: 'Última visita', key: 'last_visit', format: 'date' },
|
||||
{ header: 'Total gastado', key: 'total_spent', format: 'currency' },
|
||||
{ header: 'Adeudo', key: 'amount_due', format: 'currency' },
|
||||
{ header: 'VIP', key: 'vip' },
|
||||
],
|
||||
rows: items.map((b) => ({
|
||||
name: b.name, phone: b.phone, birth_date: b.birth_date, occurs_on: b.occurs_on,
|
||||
turning_age: b.turning_age, last_visit: b.last_visit, total_spent: b.total_spent,
|
||||
amount_due: b.amount_due, vip: b.is_vip ? 'Sí' : 'No',
|
||||
})),
|
||||
});
|
||||
toast.success(`Excel descargado (${items.length} cumpleañeros)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
@@ -76,6 +78,8 @@ const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) =>
|
||||
|
||||
const Dashboard: FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [chartData, setChartData] = useState<ChartPoint[]>([]);
|
||||
@@ -256,10 +260,10 @@ const Dashboard: FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
<Button variant="primary" size="md">
|
||||
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
||||
<Calendar size={16} className="mr-2" /> Nueva cita
|
||||
</Button>
|
||||
<Button variant="outline" size="md">
|
||||
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
||||
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
||||
</Button>
|
||||
</div>
|
||||
@@ -277,10 +281,10 @@ const Dashboard: FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
|
||||
<Button variant="outline" size="sm">
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Bienvenido
|
||||
</Button>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-theme-accent-bg text-theme-heading">
|
||||
<Sparkles size={16} />
|
||||
Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
|
||||
@@ -292,7 +296,7 @@ const Dashboard: FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30">
|
||||
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30" onClick={() => navigate('/reportes')}>
|
||||
<TrendingUp size={16} className="mr-2" />
|
||||
Ver reportes
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import MovimientosPanel from '../components/inventario/MovimientosPanel';
|
||||
import AlertasPanel from '../components/inventario/AlertasPanel';
|
||||
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'items', label: 'Items' },
|
||||
{ key: 'compras', label: 'Compras' },
|
||||
{ key: 'bajas', label: 'Bajas' },
|
||||
{ key: 'alertas', label: 'Alertas' },
|
||||
] as const;
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
@@ -50,6 +60,9 @@ const emptyItem: Partial<InventoryItem> = {
|
||||
};
|
||||
|
||||
const Inventario: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'items';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -71,6 +84,7 @@ const Inventario: FC = () => {
|
||||
const [moves, setMoves] = useState<InventoryMove[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (tab !== 'items') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getInventory({
|
||||
@@ -85,7 +99,7 @@ const Inventario: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind, level, search]);
|
||||
}, [kind, level, search, tab]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@@ -147,11 +161,37 @@ const Inventario: FC = () => {
|
||||
title="Inventario y consumibles"
|
||||
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
|
||||
>
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo item
|
||||
</Button>
|
||||
{tab === 'items' && (
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo item
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'compras' ? (
|
||||
<MovimientosPanel tipo="compra" />
|
||||
) : tab === 'bajas' ? (
|
||||
<MovimientosPanel tipo="baja" />
|
||||
) : tab === 'alertas' ? (
|
||||
<AlertasPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||||
<Card><Card.Body>
|
||||
@@ -308,6 +348,8 @@ const Inventario: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Search, Plus, Minus, Wallet } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
@@ -14,11 +15,32 @@ import {
|
||||
MobileCard,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Wallet as WalletType } from '../services/odoo';
|
||||
import { odooApi, type Wallet as WalletType, type MonederoPuntosReport } from '../services/odoo';
|
||||
|
||||
type TxType = 'accrual' | 'redemption';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'cuentas', label: 'Cuentas' },
|
||||
{ key: 'reporte', label: 'Reporte de puntos' },
|
||||
] as const;
|
||||
|
||||
const txTypeLabels: Record<string, string> = {
|
||||
accrual: 'Acumulación', redemption: 'Redención', adjustment: 'Ajuste', expiration: 'Vencimiento',
|
||||
};
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const Monedero: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'cuentas';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [wallets, setWallets] = useState<WalletType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -33,8 +55,13 @@ const Monedero: FC = () => {
|
||||
const [points, setPoints] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reporte, setReporte] = useState<MonederoPuntosReport | null>(null);
|
||||
const [reporteLoading, setReporteLoading] = useState(false);
|
||||
const [repStart, setRepStart] = useState(inicioDeMes());
|
||||
const [repEnd, setRepEnd] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (tab !== 'cuentas') return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -51,7 +78,24 @@ const Monedero: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
}, [search, page, pageSize, tab]);
|
||||
|
||||
const loadReporte = useCallback(async () => {
|
||||
if (tab !== 'reporte') return;
|
||||
try {
|
||||
setReporteLoading(true);
|
||||
const res = await odooApi.getMonederoPuntos(repStart || undefined, repEnd || undefined);
|
||||
if (res.status === 'success') {
|
||||
const { by_type, puntos_activos, cuentas, equivalente_mxn } = res;
|
||||
setReporte({ by_type, puntos_activos, cuentas, equivalente_mxn });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar reporte de puntos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setReporteLoading(false);
|
||||
}
|
||||
}, [tab, repStart, repEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -61,6 +105,10 @@ const Monedero: FC = () => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
loadReporte();
|
||||
}, [loadReporte]);
|
||||
|
||||
const openModal = (w: WalletType, type: TxType) => {
|
||||
setSelected(w);
|
||||
setTxType(type);
|
||||
@@ -103,6 +151,78 @@ const Monedero: FC = () => {
|
||||
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
||||
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'reporte' ? (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Input label="Inicio" type="date" value={repStart} onChange={(e) => setRepStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={repEnd} onChange={(e) => setRepEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{reporteLoading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : reporte ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Puntos activos</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.puntos_activos} pts</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Equivalente</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{fmtMoney(reporte.equivalente_mxn)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Cuentas con monedero</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.cuentas}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1">Movimientos del periodo</h3>
|
||||
<p className="text-xs text-theme-muted mb-4">1 punto = $1 MXN (conversión fija del sistema).</p>
|
||||
{Object.keys(reporte.by_type).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin movimientos en el periodo.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(reporte.by_type).map(([type, points]) => (
|
||||
<div key={type} className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">{txTypeLabels[type] || type}</p>
|
||||
<p className={`text-lg font-semibold ${type === 'redemption' || type === 'expiration' ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
@@ -258,6 +378,8 @@ const Monedero: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -20,9 +21,12 @@ import {
|
||||
toast,
|
||||
TextArea,
|
||||
badgeForAppointmentState,
|
||||
badgeForSaleState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
const genderOptions = [
|
||||
{ value: '', label: 'No especificado' },
|
||||
@@ -75,22 +79,66 @@ const formatBlood = (bt?: string | false) => {
|
||||
return opt?.label || bt.toUpperCase();
|
||||
};
|
||||
|
||||
const appointmentStateLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
const saleStateLabels: Record<string, string> = {
|
||||
draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada',
|
||||
};
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
|
||||
};
|
||||
const paymentStateLabels: Record<string, string> = {
|
||||
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
|
||||
};
|
||||
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
|
||||
const mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
|
||||
|
||||
const completionBadgeClasses = (pct?: number) => {
|
||||
const v = pct ?? 0;
|
||||
if (v >= 100) return 'bg-brand-mint text-theme-heading';
|
||||
if (v < 40) return 'bg-rose-100 text-rose-700';
|
||||
return 'bg-theme-warning text-theme-warning-text';
|
||||
};
|
||||
|
||||
const badgeForVisitaState = (state: string): BadgeVariant =>
|
||||
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
|
||||
|
||||
const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
{ key: 'is_pregnant', label: 'Embarazo' },
|
||||
{ key: 'heart_disease', label: 'Enfermedad cardíaca' },
|
||||
{ key: 'diabetes', label: 'Diabetes' },
|
||||
{ key: 'faints_with_needles', label: 'Se desmaya con agujas' },
|
||||
];
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState(() => searchParams.get('q') || '');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[] } | null>(null);
|
||||
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
|
||||
const [entryDetail, setEntryDetail] = useState<
|
||||
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
|
||||
>(null);
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||
const [onlyVip, setOnlyVip] = useState(false);
|
||||
const [onlyRecent, setOnlyRecent] = useState(false);
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Patient | null>(null);
|
||||
const [photo, setPhoto] = useState<string | null>(null);
|
||||
const photoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const emptyForm: PatientForm = {
|
||||
name: '',
|
||||
@@ -106,6 +154,8 @@ const Pacientes: FC = () => {
|
||||
emergency_phone: '',
|
||||
home_phone: '',
|
||||
mobile: '',
|
||||
whatsapp: '',
|
||||
primary_doctor_id: '',
|
||||
address_notes: '',
|
||||
referred_by: '',
|
||||
patient_comments: '',
|
||||
@@ -140,7 +190,15 @@ const Pacientes: FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
|
||||
const res = await odooApi.getPatients({
|
||||
search,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
incomplete: onlyIncomplete ? 1 : undefined,
|
||||
vip: onlyVip ? 1 : undefined,
|
||||
recent: onlyRecent ? 1 : undefined,
|
||||
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setPatients(res.patients);
|
||||
setTotal(res.total ?? 0);
|
||||
@@ -153,11 +211,17 @@ const Pacientes: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -169,7 +233,7 @@ const Pacientes: FC = () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getPatientHistory(patient.id);
|
||||
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
|
||||
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta });
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar historial');
|
||||
console.error(err);
|
||||
@@ -181,11 +245,13 @@ const Pacientes: FC = () => {
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setPhoto(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (patient: Patient) => {
|
||||
setEditing(patient);
|
||||
setPhoto(null);
|
||||
setForm({
|
||||
...emptyForm,
|
||||
...patient,
|
||||
@@ -193,6 +259,8 @@ const Pacientes: FC = () => {
|
||||
gender: patient.gender || '',
|
||||
blood_type: patient.blood_type || '',
|
||||
marital_status: patient.marital_status || '',
|
||||
whatsapp: patient.whatsapp || '',
|
||||
primary_doctor_id: patient.primary_doctor_id ? String(patient.primary_doctor_id) : '',
|
||||
children_count: patient.children_count ?? 0,
|
||||
is_pregnant: patient.is_pregnant ?? false,
|
||||
is_breastfeeding: patient.is_breastfeeding ?? false,
|
||||
@@ -219,6 +287,19 @@ const Pacientes: FC = () => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const onPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('La foto excede 5 MB');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setPhoto(String(reader.result || ''));
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name || !form.phone) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
@@ -232,10 +313,13 @@ const Pacientes: FC = () => {
|
||||
gender: form.gender || undefined,
|
||||
blood_type: form.blood_type || undefined,
|
||||
marital_status: form.marital_status || undefined,
|
||||
primary_doctor_id: form.primary_doctor_id ? parseInt(form.primary_doctor_id, 10) : null,
|
||||
};
|
||||
if (photo) payload.photo = photo.split(',')[1] || '';
|
||||
if (editing) {
|
||||
await odooApi.updatePatient(editing.id, payload);
|
||||
const res = await odooApi.updatePatient(editing.id, payload);
|
||||
toast.success('Paciente actualizado');
|
||||
if (selected?.id === editing.id) setSelected(res.patient);
|
||||
} else {
|
||||
await odooApi.createPatient(payload);
|
||||
toast.success('Paciente creado');
|
||||
@@ -254,6 +338,7 @@ const Pacientes: FC = () => {
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Expediente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
|
||||
@@ -285,18 +370,47 @@ const Pacientes: FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const exportPatients = () => {
|
||||
const rows: (string | number | boolean | null | undefined)[][] = [
|
||||
['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
|
||||
];
|
||||
patients.forEach((p) =>
|
||||
rows.push([
|
||||
p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
|
||||
p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
|
||||
])
|
||||
);
|
||||
downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||
toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
|
||||
const timelineItems = history
|
||||
? [
|
||||
...history.appointments.map((a) => ({ kind: 'cita' as const, date: a.date || '', item: a })),
|
||||
...history.sales.map((s) => ({ kind: 'venta' as const, date: (s.date || '').slice(0, 10), item: s })),
|
||||
...history.visitas.map((v) => ({ kind: 'visita' as const, date: (v.date_start || '').slice(0, 10), item: v })),
|
||||
].sort((a, b) => b.date.localeCompare(a.date))
|
||||
: [];
|
||||
|
||||
const exportPatients = async () => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Pacientes',
|
||||
title: 'Pacientes SKEEN',
|
||||
subtitle: `${patients.length} pacientes de la vista actual`,
|
||||
columns: [
|
||||
{ header: 'Expediente', key: 'patient_id' },
|
||||
{ header: 'Nombre', key: 'name' },
|
||||
{ header: 'Teléfono', key: 'phone' },
|
||||
{ header: 'Email', key: 'email' },
|
||||
{ header: 'Fecha nacimiento', key: 'birth_date', format: 'date' },
|
||||
{ header: 'Género', key: 'gender' },
|
||||
{ header: 'Última visita', key: 'last_visit', format: 'date' },
|
||||
{ header: 'Visitas', key: 'total_visits', format: 'number' },
|
||||
{ header: 'Puntos monedero', key: 'wallet_points', format: 'number' },
|
||||
{ header: 'Total gastado', key: 'total_spent', format: 'currency' },
|
||||
{ header: 'Fuente', key: 'source' },
|
||||
{ header: 'VIP', key: 'vip' },
|
||||
],
|
||||
rows: patients.map((p) => ({
|
||||
patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email,
|
||||
birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit,
|
||||
total_visits: p.total_visits, wallet_points: p.wallet_points,
|
||||
total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No',
|
||||
})),
|
||||
});
|
||||
toast.success(`Excel descargado (${patients.length} pacientes de la vista actual)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -324,6 +438,49 @@ const Pacientes: FC = () => {
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Todos los médicos' },
|
||||
...doctors.map((d) => ({
|
||||
value: String(d.id),
|
||||
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
|
||||
})),
|
||||
]}
|
||||
value={doctorFilter}
|
||||
onChange={(e) => setDoctorFilter(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtros rápidos */}
|
||||
<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: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
|
||||
] as const).map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => chip.set(!chip.active)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
chip.active
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
{(onlyVip || onlyRecent || onlyIncomplete || doctorFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
|
||||
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||
>
|
||||
Quitar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
@@ -350,6 +507,14 @@ const Pacientes: FC = () => {
|
||||
{p.name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
|
||||
title={p.expediente_missing && p.expediente_missing.length > 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
|
||||
>
|
||||
{p.expediente_completion ?? 0}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.phone}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.age ?? '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.last_visit || '-'}</td>
|
||||
@@ -378,6 +543,7 @@ const Pacientes: FC = () => {
|
||||
title={p.name}
|
||||
subtitle={p.phone}
|
||||
rows={[
|
||||
{ label: 'Expediente', value: `${p.expediente_completion ?? 0}%` },
|
||||
{ label: 'Edad', value: p.age ?? '-' },
|
||||
{ label: 'Última visita', value: p.last_visit || '-' },
|
||||
{ label: 'Visitas', value: p.total_visits },
|
||||
@@ -441,6 +607,26 @@ const Pacientes: FC = () => {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
|
||||
{/* Foto del paciente */}
|
||||
<div className="flex items-center gap-4">
|
||||
{photo ? (
|
||||
<img src={photo} alt="Foto" className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
|
||||
) : editing?.has_photo && editing.photo_url ? (
|
||||
<img src={odooApi.patientPhotoUrl(editing.photo_url)} alt={editing.name} className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-theme-accent-bg flex items-center justify-center text-theme-muted shrink-0">
|
||||
<Camera size={22} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input ref={photoRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
|
||||
<Button variant="outline" size="sm" onClick={() => photoRef.current?.click()}>
|
||||
<Camera size={14} className="mr-1.5" /> Subir foto
|
||||
</Button>
|
||||
<p className="text-xs text-theme-muted mt-1">JPG o PNG, máx 5 MB.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<User size={16} className="mr-2 text-theme-muted" /> Datos generales
|
||||
@@ -451,9 +637,16 @@ const Pacientes: FC = () => {
|
||||
<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 })} />
|
||||
<Input label="WhatsApp" value={form.whatsapp} onChange={(e) => updateForm({ whatsapp: e.target.value })} />
|
||||
<Input label="Fecha de nacimiento" type="date" value={form.birth_date} onChange={(e) => updateForm({ birth_date: e.target.value })} />
|
||||
<Select label="Género" options={genderOptions} value={form.gender} onChange={(e) => updateForm({ gender: e.target.value })} />
|
||||
<Select label="Tipo sanguíneo" options={bloodOptions} value={form.blood_type} onChange={(e) => updateForm({ blood_type: e.target.value })} />
|
||||
<Select
|
||||
label="Médico asignado"
|
||||
options={[{ value: '', label: 'Sin asignar' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={form.primary_doctor_id}
|
||||
onChange={(e) => updateForm({ primary_doctor_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -570,14 +763,85 @@ const Pacientes: FC = () => {
|
||||
onClose={() => setSelected(null)}
|
||||
title={selected?.name || 'Detalle del paciente'}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar paciente
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Encabezado con foto */}
|
||||
{selected && (
|
||||
<div className="flex items-center gap-3">
|
||||
{selected.has_photo && selected.photo_url ? (
|
||||
<img
|
||||
src={odooApi.patientPhotoUrl(selected.photo_url)}
|
||||
alt={selected.name}
|
||||
className="w-14 h-14 rounded-full object-cover border border-theme-border shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-full bg-theme-accent-bg flex items-center justify-center text-lg font-bold text-theme-heading shrink-0">
|
||||
{selected.name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase()).join('')}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-theme-heading truncate">{selected.name}</p>
|
||||
<p className="text-xs text-theme-muted truncate">
|
||||
{selected.patient_id}{selected.primary_doctor ? ` · Médico: ${selected.primary_doctor}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alertas clínicas */}
|
||||
{selected?.allergies && (
|
||||
<div className="flex items-start gap-2 p-3 bg-rose-100 text-rose-700 rounded-xl text-sm">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||
<p><span className="font-semibold">Alergias:</span> {selected.allergies}</p>
|
||||
</div>
|
||||
)}
|
||||
{selected && CRITICAL_ALERTS.some(({ key }) => selected[key]) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRITICAL_ALERTS.filter(({ key }) => selected[key]).map(({ key, label }) => (
|
||||
<span key={key} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-theme-warning text-theme-warning-text">
|
||||
<AlertTriangle size={12} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completitud del expediente */}
|
||||
{selected && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-theme-muted">Completitud del expediente</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{selected.expediente_completion ?? 0}%</p>
|
||||
</div>
|
||||
<div className="h-2 bg-theme-surface border border-theme-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${(selected.expediente_completion ?? 0) >= 100 ? 'bg-brand-mint' : 'bg-brand-coral'}`}
|
||||
style={{ width: `${selected.expediente_completion ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{selected.expediente_missing && selected.expediente_missing.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-theme-muted">Falta: {selected.expediente_missing.join(', ')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(selected)}>
|
||||
Completar expediente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
|
||||
{([
|
||||
{ key: 'info', label: 'Información general' },
|
||||
{ key: 'clinical', label: 'Historia clínica' },
|
||||
{ key: 'appointments', label: 'Citas' },
|
||||
{ key: 'sales', label: 'Ventas' },
|
||||
{ key: 'timeline', label: 'Historial' },
|
||||
{ key: 'docs', label: 'Documentos' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -595,10 +859,37 @@ const Pacientes: FC = () => {
|
||||
<Skeleton count={4} className="h-16 w-full" />
|
||||
) : tab === 'info' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Estado de cuenta */}
|
||||
{history?.estado_cuenta && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Puntos monedero</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.puntos_monedero} pts</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Total gastado</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">${history.estado_cuenta.total_gastado}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Adeudo pendiente</p>
|
||||
<p className={`text-sm font-semibold ${history.estado_cuenta.adeudo > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
${history.estado_cuenta.adeudo}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Total visitas</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.visitas_total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
|
||||
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
|
||||
<InfoRow label="Celular" value={selected?.mobile} icon={Phone} />
|
||||
<InfoRow label="WhatsApp" value={selected?.whatsapp} icon={Phone} />
|
||||
<InfoRow label="Email" value={selected?.email} icon={Mail} />
|
||||
<InfoRow label="Teléfono casa" value={selected?.home_phone} icon={Phone} />
|
||||
<InfoRow label="Fecha de nacimiento" value={selected?.birth_date} icon={Calendar} />
|
||||
@@ -611,6 +902,7 @@ const Pacientes: FC = () => {
|
||||
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
|
||||
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
|
||||
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
|
||||
<InfoRow label="Médico asignado" value={selected?.primary_doctor || 'Sin asignar'} icon={Stethoscope} />
|
||||
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
|
||||
</div>
|
||||
{selected?.address_notes && (
|
||||
@@ -634,27 +926,39 @@ const Pacientes: FC = () => {
|
||||
</div>
|
||||
) : tab === 'clinical' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
|
||||
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
|
||||
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
|
||||
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
|
||||
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
|
||||
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
|
||||
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
|
||||
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
|
||||
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
|
||||
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
|
||||
<ClinicalItem label="Colitis" value={selected?.colitis} />
|
||||
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
|
||||
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
|
||||
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
|
||||
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
|
||||
<ClinicalItem label="Migraña" value={selected?.migraine} />
|
||||
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
|
||||
<Baby size={16} className="mr-2 text-theme-muted" /> Gineco-obstétricos
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
|
||||
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
|
||||
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
|
||||
<InfoRow label="Número de hijos" value={selected?.children_count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InfoRow label="Número de hijos" value={selected?.children_count} />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
|
||||
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Antecedentes
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
|
||||
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
|
||||
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
|
||||
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
|
||||
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
|
||||
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
|
||||
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
|
||||
<ClinicalItem label="Colitis" value={selected?.colitis} />
|
||||
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
|
||||
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
|
||||
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
|
||||
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
|
||||
<ClinicalItem label="Migraña" value={selected?.migraine} />
|
||||
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Alergias', value: selected?.allergies },
|
||||
@@ -671,43 +975,208 @@ const Pacientes: FC = () => {
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'appointments' ? (
|
||||
history && history.appointments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{history.appointments.map((a) => (
|
||||
<div key={a.id} className="p-3 border border-theme-border rounded-xl text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">
|
||||
{a.date} {a.time}
|
||||
</p>
|
||||
<Badge variant={badgeForAppointmentState(a.state)}>{a.state}</Badge>
|
||||
</div>
|
||||
<p className="text-theme-muted mt-1">{a.service}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Sin citas" subtitle="Este paciente no tiene citas registradas." icon={<Activity size={28} />} />
|
||||
)
|
||||
) : tab === 'docs' ? (
|
||||
<div className="space-y-6">
|
||||
{selected && (
|
||||
<>
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<FileText size={16} className="mr-2 text-theme-muted" /> Expediente escaneado
|
||||
</h4>
|
||||
<PatientAdjuntos patientId={selected.id} kind="expediente" />
|
||||
</section>
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<ClipboardList size={16} className="mr-2 text-theme-muted" /> Galería de imágenes
|
||||
</h4>
|
||||
<PatientAdjuntos patientId={selected.id} kind="imagen" />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
history && history.sales.length > 0 ? (
|
||||
timelineItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{history.sales.map((s) => (
|
||||
<div key={s.id} className="p-3 border border-theme-border rounded-xl text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">{s.name}</p>
|
||||
<p className="font-semibold text-theme-heading">${s.total}</p>
|
||||
{timelineItems.map((entry) => (
|
||||
<button
|
||||
key={`${entry.kind}-${entry.item.id}`}
|
||||
type="button"
|
||||
onClick={() => setEntryDetail(entry)}
|
||||
className="w-full text-left p-3 border border-theme-border rounded-xl text-sm transition-all cursor-pointer hover:shadow-card hover:border-theme-border-strong"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-theme-heading flex items-center">
|
||||
{entry.kind === 'cita' ? (
|
||||
<Calendar size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
||||
) : entry.kind === 'visita' ? (
|
||||
<ClipboardList size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
||||
) : (
|
||||
<ShoppingBag size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
||||
)}
|
||||
{entry.kind === 'cita' ? `${entry.item.date} ${entry.item.time}` : entry.date}
|
||||
</p>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge variant={
|
||||
entry.kind === 'cita'
|
||||
? badgeForAppointmentState(entry.item.state)
|
||||
: entry.kind === 'visita'
|
||||
? badgeForVisitaState(entry.item.state)
|
||||
: badgeForSaleState(entry.item.state)
|
||||
}>
|
||||
{entry.kind === 'cita'
|
||||
? (appointmentStateLabels[entry.item.state] || entry.item.state)
|
||||
: entry.kind === 'visita'
|
||||
? (visitaStateLabels[entry.item.state] || entry.item.state)
|
||||
: (saleStateLabels[entry.item.state] || entry.item.state)}
|
||||
</Badge>
|
||||
<ChevronRight size={14} className="text-theme-muted" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-theme-muted mt-1">{s.date} — {s.state}</p>
|
||||
</div>
|
||||
<p className="text-theme-muted mt-1">
|
||||
{entry.kind === 'cita'
|
||||
? entry.item.service
|
||||
: entry.kind === 'visita'
|
||||
? (entry.item.motivo || entry.item.servicio || 'Visita clínica')
|
||||
: `${entry.item.name} — $${entry.item.total}`}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Sin ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
|
||||
<EmptyState title="Sin historial" subtitle="Este paciente no tiene citas, ventas ni visitas registradas." icon={<Activity size={28} />} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal detalle de cita/venta/visita del historial */}
|
||||
<Modal
|
||||
isOpen={!!entryDetail}
|
||||
onClose={() => setEntryDetail(null)}
|
||||
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
|
||||
maxWidth="lg"
|
||||
>
|
||||
{entryDetail?.kind === 'cita' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForAppointmentState(entryDetail.item.state)}>
|
||||
{appointmentStateLabels[entryDetail.item.state] || entryDetail.item.state}
|
||||
</Badge>
|
||||
<Badge variant={entryDetail.item.payment_state === 'paid' ? 'paid' : entryDetail.item.payment_state === 'partial' ? 'warning' : 'unpaid'}>
|
||||
{paymentStateLabels[entryDetail.item.payment_state] || entryDetail.item.payment_state}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
|
||||
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
|
||||
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
|
||||
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
|
||||
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
|
||||
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
|
||||
{entryDetail.item.package_finished && (
|
||||
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
|
||||
)}
|
||||
</div>
|
||||
{entryDetail.item.notes && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Notas</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{entryDetail.item.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{entryDetail?.kind === 'venta' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForSaleState(entryDetail.item.state)}>
|
||||
{saleStateLabels[entryDetail.item.state] || entryDetail.item.state}
|
||||
</Badge>
|
||||
{entryDetail.item.refunded && (
|
||||
<Badge variant="danger">Reembolsada ${entryDetail.item.refund_amount}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
|
||||
<InfoRow label="Fecha" value={entryDetail.item.date} icon={Calendar} />
|
||||
<InfoRow label="Subtotal" value={`$${entryDetail.item.subtotal}`} />
|
||||
<InfoRow label="Descuento" value={`$${entryDetail.item.discount}`} />
|
||||
<InfoRow label="Impuesto" value={`$${entryDetail.item.tax}`} />
|
||||
<InfoRow label="Total" value={`$${entryDetail.item.total}`} />
|
||||
<InfoRow label="Pagado" value={`$${entryDetail.item.amount_paid}`} />
|
||||
<InfoRow label="Por cobrar" value={`$${entryDetail.item.amount_due}`} />
|
||||
</div>
|
||||
{entryDetail.item.refunded && entryDetail.item.refund_reason && (
|
||||
<div className="p-3 bg-rose-100 rounded-xl">
|
||||
<p className="text-xs text-rose-700 mb-1">Motivo de reembolso{entryDetail.item.refunded_at ? ` · ${entryDetail.item.refunded_at}` : ''}</p>
|
||||
<p className="text-sm text-rose-700">{entryDetail.item.refund_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
|
||||
<div className="border border-theme-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</th>
|
||||
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Cant.</th>
|
||||
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Precio</th>
|
||||
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{entryDetail.item.lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="p-2.5 text-theme-heading">
|
||||
{line.service || line.description}
|
||||
{line.prescribed_by && (
|
||||
<span className="block text-xs text-theme-muted">Recetó: {line.prescribed_by}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2.5 text-right text-theme-muted">{line.quantity}</td>
|
||||
<td className="p-2.5 text-right text-theme-muted">${line.price_unit}</td>
|
||||
<td className="p-2.5 text-right font-medium text-theme-heading">${line.subtotal}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entryDetail?.kind === 'visita' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForVisitaState(entryDetail.item.state)}>
|
||||
{visitaStateLabels[entryDetail.item.state] || entryDetail.item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
|
||||
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
|
||||
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Motivo de consulta', value: entryDetail.item.motivo },
|
||||
{ label: 'Diagnóstico', value: entryDetail.item.diagnostico },
|
||||
{ label: 'Tratamiento', value: entryDetail.item.tratamiento },
|
||||
{ label: 'Notas', value: entryDetail.item.notas },
|
||||
].map(({ label, value }) =>
|
||||
value ? (
|
||||
<div key={label} className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">{label}</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{value}</p>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -726,6 +1195,8 @@ interface PatientForm {
|
||||
emergency_phone: string;
|
||||
home_phone: string;
|
||||
mobile: string;
|
||||
whatsapp: string;
|
||||
primary_doctor_id: string;
|
||||
address_notes: string;
|
||||
referred_by: string;
|
||||
patient_comments: string;
|
||||
|
||||
@@ -22,6 +22,17 @@ const methods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago', 'Stripe'
|
||||
|
||||
const methodOptions = methods.map((m) => ({ value: m, label: m }));
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const metodoLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
const estadoLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', processing: 'Procesando', completed: 'Completado',
|
||||
failed: 'Fallido', refunded: 'Reembolsado',
|
||||
};
|
||||
|
||||
const Pagos: FC = () => {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -32,6 +43,8 @@ const Pagos: FC = () => {
|
||||
const [patient, setPatient] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState('Efectivo');
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
const [detalle, setDetalle] = useState<Payment | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -95,6 +108,11 @@ const Pagos: FC = () => {
|
||||
...patients.map((p) => ({ value: p.name, label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
// Filtro por día (create_date "YYYY-MM-DD HH:MM"); vacío = todos
|
||||
const visibles = fecha
|
||||
? payments.filter((p) => (p.create_date || '').startsWith(fecha))
|
||||
: payments;
|
||||
|
||||
return (
|
||||
<Layout title="Pagos" subtitle="Transacciones">
|
||||
<PageHeader title="Pagos" subtitle="Crea y confirma pagos">
|
||||
@@ -106,14 +124,33 @@ const Pagos: FC = () => {
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<Input
|
||||
label="Fecha"
|
||||
type="date"
|
||||
value={fecha}
|
||||
onChange={(e) => setFecha(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
<Button
|
||||
variant={fecha === '' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFecha(fecha === '' ? hoy() : '')}
|
||||
className="mb-1"
|
||||
>
|
||||
Todos
|
||||
</Button>
|
||||
<p className="text-sm text-theme-muted mb-1 sm:ml-auto">{visibles.length} cobros</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : payments.length === 0 ? (
|
||||
) : visibles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin pagos"
|
||||
subtitle="No hay pagos registrados."
|
||||
subtitle={fecha ? 'No hay cobros registrados en esta fecha.' : 'No hay pagos registrados.'}
|
||||
actionLabel="Nuevo pago"
|
||||
onAction={() => setModalOpen(true)}
|
||||
icon={<CreditCard size={28} />}
|
||||
@@ -133,17 +170,17 @@ const Pagos: FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
{visibles.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => setDetalle(p)}>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.name}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{p.patient}</td>
|
||||
<td className="p-3 text-sm font-semibold text-theme-heading">${p.amount}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.payment_method}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{metodoLabels[p.payment_method] || p.payment_method}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge>
|
||||
<Badge variant={badgeForPaymentState(p.state)}>{estadoLabels[p.state] || p.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||
{p.state !== 'confirmed' && p.state !== 'posted' && p.state !== 'completed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)} title="Confirmar">
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
@@ -156,18 +193,19 @@ const Pagos: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{payments.map((p) => (
|
||||
{visibles.map((p) => (
|
||||
<MobileCard
|
||||
key={p.id}
|
||||
title={p.patient}
|
||||
subtitle={p.name}
|
||||
rows={[
|
||||
{ label: 'Monto', value: `$${p.amount}` },
|
||||
{ label: 'Método', value: p.payment_method },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge> },
|
||||
{ label: 'Método', value: metodoLabels[p.payment_method] || p.payment_method },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{estadoLabels[p.state] || p.state}</Badge> },
|
||||
]}
|
||||
onClick={() => setDetalle(p)}
|
||||
actions={
|
||||
p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||
p.state !== 'confirmed' && p.state !== 'posted' && p.state !== 'completed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)}>
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
@@ -181,6 +219,54 @@ const Pagos: FC = () => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal detalle del cobro */}
|
||||
<Modal
|
||||
isOpen={!!detalle}
|
||||
onClose={() => setDetalle(null)}
|
||||
title={detalle ? `Cobro ${detalle.name}` : 'Detalle del cobro'}
|
||||
maxWidth="md"
|
||||
>
|
||||
{detalle && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeForPaymentState(detalle.state)}>{estadoLabels[detalle.state] || detalle.state}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Paciente</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detalle.patient}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Monto</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">${detalle.amount}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Método</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{metodoLabels[detalle.payment_method] || detalle.payment_method}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Fecha de registro</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detalle.create_date || '-'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Referencia proveedor</p>
|
||||
<p className="text-sm font-medium text-theme-heading break-words">{detalle.provider_reference || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{detalle.payment_url && (
|
||||
<a
|
||||
href={detalle.payment_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-sm text-theme-heading underline"
|
||||
>
|
||||
Abrir link de pago
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
|
||||
601
frontend/src/pages/Pos.tsx
Normal file
601
frontend/src/pages/Pos.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service, type Patient, type PosCheckoutResult } from '../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
|
||||
const METODO_OPTIONS = [
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'card', label: 'Tarjeta' },
|
||||
{ value: 'transfer', label: 'Transferencia' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'mercadopago', label: 'MercadoPago' },
|
||||
];
|
||||
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
|
||||
|
||||
interface TicketLine {
|
||||
service: Service;
|
||||
qty: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
const Pos: FC = () => {
|
||||
// Catálogo
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoria, setCategoria] = useState('');
|
||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||
|
||||
// Ticket
|
||||
const [lines, setLines] = useState<TicketLine[]>([]);
|
||||
const [discount, setDiscount] = useState('');
|
||||
const [paciente, setPaciente] = useState<Patient | null>(null);
|
||||
const [pacienteQuery, setPacienteQuery] = useState('');
|
||||
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
|
||||
const [altaRapida, setAltaRapida] = useState(false);
|
||||
const [altaForm, setAltaForm] = useState({ name: '', phone: '' });
|
||||
|
||||
// Cobro
|
||||
const [cobroOpen, setCobroOpen] = useState(false);
|
||||
const [metodo, setMetodo] = useState('cash');
|
||||
const [recibido, setRecibido] = useState('');
|
||||
const [conPuntos, setConPuntos] = useState(false);
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
|
||||
// Resultado
|
||||
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
|
||||
|
||||
// Corte de caja
|
||||
const [cajaAbierta, setCajaAbierta] = useState(true);
|
||||
|
||||
// Mobile
|
||||
const [ticketMovil, setTicketMovil] = useState(false);
|
||||
|
||||
// ---- Catálogo ----
|
||||
const loadServices = useCallback(async () => {
|
||||
try {
|
||||
setCatalogLoading(true);
|
||||
const res = await odooApi.getServices({ search: search || undefined, category: categoria || undefined, page_size: 200 });
|
||||
if (res.status === 'success') setServices(res.services);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar servicios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, [search, categoria]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(loadServices, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [loadServices]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
|
||||
.then((res) => {
|
||||
if (res.status === 'success') setCajaAbierta(res.cash_closings.some((c) => c.state === 'open'));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Búsqueda server-side de paciente
|
||||
useEffect(() => {
|
||||
if (paciente || altaRapida) return;
|
||||
if (pacienteQuery.trim().length < 2) {
|
||||
setPacienteResults([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
|
||||
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [pacienteQuery, paciente, altaRapida]);
|
||||
|
||||
const categorias = useMemo(() => {
|
||||
const set = new Map<string, string>();
|
||||
services.forEach((s) => { if (s.category) set.set(s.category, s.category); });
|
||||
return [...set.keys()];
|
||||
}, [services]);
|
||||
|
||||
const visibles = useMemo(
|
||||
() => (soloFavoritos ? services.filter((s) => s.is_favorite) : services),
|
||||
[services, soloFavoritos]
|
||||
);
|
||||
|
||||
// ---- Ticket ----
|
||||
const addLine = (service: Service) => {
|
||||
setLines((prev) => {
|
||||
const found = prev.find((l) => l.service.id === service.id);
|
||||
if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l));
|
||||
return [...prev, { service, qty: 1, price: service.price }];
|
||||
});
|
||||
};
|
||||
|
||||
const updateLine = (id: number, patch: Partial<TicketLine>) => {
|
||||
setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
|
||||
const removeLine = (id: number) => setLines((prev) => prev.filter((l) => l.service.id !== id));
|
||||
|
||||
const limpiarTicket = () => {
|
||||
if (lines.length === 0) return;
|
||||
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
|
||||
};
|
||||
|
||||
const subtotal = lines.reduce((a, l) => a + l.qty * l.price, 0);
|
||||
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
|
||||
const total = Math.max(0, subtotal - discountNum);
|
||||
const numItems = lines.reduce((a, l) => a + l.qty, 0);
|
||||
const puntosPaciente = paciente?.wallet_points ?? 0;
|
||||
const puedePuntos = puntosPaciente >= total && total > 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, (parseFloat(recibido) || 0) - total) : 0;
|
||||
|
||||
const crearPacienteRapido = async () => {
|
||||
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await odooApi.createPatient({ name: altaForm.name.trim(), phone: altaForm.phone.trim() });
|
||||
if (res.status === 'success') {
|
||||
setPaciente(res.patient);
|
||||
setAltaRapida(false);
|
||||
setAltaForm({ name: '', phone: '' });
|
||||
toast.success('Paciente creado');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al crear paciente');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const abrirCobro = () => {
|
||||
if (!paciente) {
|
||||
toast.error('Selecciona un paciente');
|
||||
return;
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
toast.error('El ticket está vacío');
|
||||
return;
|
||||
}
|
||||
setMetodo('cash');
|
||||
setRecibido('');
|
||||
setConPuntos(false);
|
||||
setCobroOpen(true);
|
||||
};
|
||||
|
||||
const cobrar = async () => {
|
||||
if (!paciente) return;
|
||||
try {
|
||||
setCobrando(true);
|
||||
const res = await odooApi.posCheckout({
|
||||
partner_id: paciente.id,
|
||||
lines: lines.map((l) => ({ service_id: l.service.id, quantity: l.qty, price_unit: l.price })),
|
||||
discount: discountNum,
|
||||
payment_method: metodo,
|
||||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||||
pay_with_points: conPuntos,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setResultado(res);
|
||||
setCobroOpen(false);
|
||||
toast.success(`Venta ${res.sale.name} cobrada`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al cobrar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCobrando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nuevaVenta = () => {
|
||||
setResultado(null);
|
||||
setLines([]);
|
||||
setDiscount('');
|
||||
setPaciente(null);
|
||||
setPacienteQuery('');
|
||||
};
|
||||
|
||||
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
||||
const pacienteBlock = paciente ? (
|
||||
<div className="flex items-center justify-between p-2.5 bg-theme-bg rounded-xl">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{paciente.name}</p>
|
||||
<p className="text-xs text-theme-muted flex items-center gap-1.5">
|
||||
{paciente.phone}
|
||||
{puntosPaciente > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteQuery(''); }}>Cambiar</Button>
|
||||
</div>
|
||||
) : altaRapida ? (
|
||||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
||||
<Input placeholder="Nombre *" value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
|
||||
<Input placeholder="Teléfono *" value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>Cancelar</Button>
|
||||
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar paciente por nombre o teléfono..."
|
||||
value={pacienteQuery}
|
||||
onChange={(e) => setPacienteQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
{pacienteResults.length > 0 && (
|
||||
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
|
||||
{pacienteResults.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { setPaciente(p); setPacienteResults([]); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg transition"
|
||||
>
|
||||
<span className="font-medium text-theme-heading">{p.name}</span>
|
||||
<span className="text-theme-muted"> · {p.phone}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAltaRapida(true); setPacienteResults([]); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-theme-heading hover:bg-theme-bg border-t border-theme-border flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> Alta rápida de paciente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{pacienteQuery.trim().length >= 2 && pacienteResults.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAltaRapida(true)}
|
||||
className="mt-1.5 text-xs text-theme-heading underline flex items-center gap-1"
|
||||
>
|
||||
<Plus size={12} /> Alta rápida de paciente
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const linesBlock = lines.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted text-center py-8">Toca un servicio para agregarlo al ticket.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-theme-border">
|
||||
{lines.map((l) => (
|
||||
<div key={l.service.id} className="flex items-center gap-1.5 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(l.service.id)}
|
||||
className="text-theme-muted hover:text-rose-600 shrink-0"
|
||||
title="Quitar"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.service.name}>
|
||||
{l.service.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
|
||||
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
|
||||
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: l.qty + 1 })}><Plus size={12} /></Button>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={50}
|
||||
value={l.price}
|
||||
onChange={(e) => updateLine(l.service.id, { price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
|
||||
title="Precio unitario"
|
||||
/>
|
||||
<span className="w-[72px] shrink-0 text-sm font-semibold text-theme-heading text-right">{fmtMoney(l.qty * l.price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const totalsBlock = (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-theme-muted">Subtotal</span>
|
||||
<span className="text-theme-heading">{fmtMoney(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="text-theme-muted">Descuento</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={50}
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-theme-heading">Total</span>
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
onClick={abrirCobro}
|
||||
disabled={lines.length === 0 || !paciente}
|
||||
>
|
||||
<ShoppingBag size={18} className="mr-2" />
|
||||
Cobrar {fmtMoney(total)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
|
||||
{!cajaAbierta && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
Sin corte de caja abierto hoy
|
||||
<Link to="/cortes" className="font-semibold underline">Abrir caja</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start">
|
||||
{/* Catálogo */}
|
||||
<Card className="flex-1 min-w-0 w-full">
|
||||
<Card.Body>
|
||||
<div className="relative mb-3">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar servicio..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSoloFavoritos((v) => !v)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
|
||||
soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
<Star size={12} fill={soloFavoritos ? 'currentColor' : 'none'} /> Favoritos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCategoria(''); setSoloFavoritos(false); }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
|
||||
categoria === '' && !soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{categorias.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => { setCategoria(c); setSoloFavoritos(false); }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition capitalize ${
|
||||
categoria === c ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{catalogLoading ? (
|
||||
<Skeleton count={8} className="h-16 w-full" />
|
||||
) : visibles.length === 0 ? (
|
||||
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{visibles.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => addLine(s)}
|
||||
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
|
||||
>
|
||||
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={s.name}>
|
||||
{s.name}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-theme-muted">{s.duration_min} min</span>
|
||||
<span className="text-sm font-bold text-theme-heading">{fmtMoney(s.price)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Ticket desktop: panel sticky full-height */}
|
||||
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
|
||||
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
||||
{lines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
|
||||
<Trash2 size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{pacienteBlock}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
|
||||
{linesBlock}
|
||||
</div>
|
||||
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
|
||||
{totalsBlock}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Botón flotante móvil */}
|
||||
{lines.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTicketMovil(true)}
|
||||
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
|
||||
>
|
||||
Ver ticket ({numItems}) {fmtMoney(total)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ticket móvil */}
|
||||
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
|
||||
<div className="space-y-3">
|
||||
{pacienteBlock}
|
||||
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
|
||||
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal de cobro */}
|
||||
<Modal
|
||||
isOpen={cobroOpen}
|
||||
onClose={() => setCobroOpen(false)}
|
||||
title={`Cobrar ${fmtMoney(total)}`}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCobroOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={cobrar} loading={cobrando}>Confirmar cobro</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{metodo === 'cash' && !conPuntos && (
|
||||
<>
|
||||
<Input
|
||||
label="Recibido"
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={recibido}
|
||||
onChange={(e) => setRecibido(e.target.value)}
|
||||
placeholder={String(total)}
|
||||
/>
|
||||
{recibido && (
|
||||
<p className="text-sm">
|
||||
<span className="text-theme-muted">Cambio: </span>
|
||||
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)}</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{puedePuntos && (
|
||||
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={conPuntos}
|
||||
onChange={(e) => setConPuntos(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-theme-border-strong"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading flex items-center gap-1.5">
|
||||
<Wallet size={15} className="text-theme-muted" />
|
||||
Pagar con puntos ({puntosPaciente} pts disponibles)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{conPuntos && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Ticket de éxito */}
|
||||
<Modal
|
||||
isOpen={!!resultado}
|
||||
onClose={nuevaVenta}
|
||||
title="Venta cobrada"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer size={16} className="mr-2" /> Imprimir
|
||||
</Button>
|
||||
<Button onClick={nuevaVenta}>Nueva venta</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{resultado && (
|
||||
<div className="print-ticket text-sm">
|
||||
<div className="text-center border-b border-dashed border-theme-border pb-3 mb-3">
|
||||
<p className="font-heading font-bold text-theme-heading">SKEEN Derma Experts</p>
|
||||
<p className="text-xs text-theme-muted">Playas de Rosarito, B.C.</p>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-theme-muted mb-2">
|
||||
<span>{resultado.sale.name}</span>
|
||||
<span>{resultado.sale.date}</span>
|
||||
</div>
|
||||
<p className="text-sm mb-3"><span className="text-theme-muted">Paciente: </span><span className="font-medium text-theme-heading">{resultado.sale.patient}</span></p>
|
||||
<table className="w-full text-sm mb-3">
|
||||
<tbody>
|
||||
{resultado.sale.lines.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.description}</td>
|
||||
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="border-t border-dashed border-theme-border pt-2 space-y-1 text-sm">
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Subtotal</span><span>{fmtMoney(resultado.sale.subtotal)}</span></div>
|
||||
{resultado.sale.discount > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Descuento</span><span>-{fmtMoney(resultado.sale.discount)}</span></div>
|
||||
)}
|
||||
{resultado.puntos_usados > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Puntos canjeados</span><span>-{resultado.puntos_usados} pts</span></div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div>
|
||||
{resultado.cambio > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div>
|
||||
)}
|
||||
{resultado.puntos_ganados > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div>
|
||||
)}
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Saldo monedero</span><span>{resultado.wallet_points} pts</span></div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-theme-muted mt-4">¡Gracias por su visita!</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pos;
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Package, Search } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Input, Badge, EmptyState, PageHeader, Skeleton, MobileCard, toast } from '../components/ui';
|
||||
import { odooApi, type Product } from '../services/odoo';
|
||||
|
||||
const translateType = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
product: 'Producto almacenable',
|
||||
service: 'Servicio',
|
||||
consu: 'Consumible',
|
||||
};
|
||||
return map[type] || type;
|
||||
};
|
||||
|
||||
const Productos: FC = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [filtered, setFiltered] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getProducts();
|
||||
if (res.status === 'success') {
|
||||
setProducts(res.products);
|
||||
setFiltered(res.products);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar productos');
|
||||
toast.error('Error al cargar productos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const term = search.toLowerCase();
|
||||
setFiltered(
|
||||
products.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(term) ||
|
||||
(p.default_code && p.default_code.toLowerCase().includes(term)) ||
|
||||
p.type.toLowerCase().includes(term)
|
||||
)
|
||||
);
|
||||
}, [search, products]);
|
||||
|
||||
return (
|
||||
<Layout title="Productos" subtitle="Inventario">
|
||||
<PageHeader title="Productos" subtitle="Catálogo de productos" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre, código o tipo..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState title="Sin productos" subtitle="No se encontraron productos." icon={<Package size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Precio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{p.default_code || '-'}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{p.name}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="default">{translateType(p.type)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm font-semibold text-theme-heading">${p.list_price}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filtered.map((p) => (
|
||||
<MobileCard
|
||||
key={p.id}
|
||||
title={p.name}
|
||||
subtitle={<Badge variant="default">{translateType(p.type)}</Badge>}
|
||||
rows={[
|
||||
{ label: 'Referencia', value: p.default_code || '-' },
|
||||
{ label: 'Precio', value: `$${p.list_price}` },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Productos;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Download, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
@@ -14,13 +15,51 @@ import {
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import Layout from '../components/Layout';
|
||||
import DailyReport from '../components/reportes/DailyReport';
|
||||
import CortesReport from '../components/reportes/CortesReport';
|
||||
import IngresosReport from '../components/reportes/IngresosReport';
|
||||
import InventarioReport from '../components/reportes/InventarioReport';
|
||||
import AdeudosReport from '../components/reportes/AdeudosReport';
|
||||
import ComisionesReport from '../components/reportes/ComisionesReport';
|
||||
import PagosServiciosReport from '../components/reportes/PagosServiciosReport';
|
||||
import PagosClientesReport from '../components/reportes/PagosClientesReport';
|
||||
import DevolucionesReport from '../components/reportes/DevolucionesReport';
|
||||
import TopClientesReport from '../components/reportes/TopClientesReport';
|
||||
import ExportarReport from '../components/reportes/ExportarReport';
|
||||
import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
|
||||
import PaquetesReport from '../components/reportes/PaquetesReport';
|
||||
import VendedoresReport from '../components/reportes/VendedoresReport';
|
||||
import ConcentradoReport from '../components/reportes/ConcentradoReport';
|
||||
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
|
||||
import KpisReport from '../components/reportes/KpisReport';
|
||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { CommissionRow } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
||||
|
||||
const TABS = [
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'diario', label: 'Movimientos diarios' },
|
||||
{ key: 'cortes', label: 'Cortes de caja' },
|
||||
{ key: 'ingresos', label: 'Ingresos' },
|
||||
{ key: 'inventario', label: 'Inventario' },
|
||||
{ key: 'adeudos', label: 'Adeudos' },
|
||||
{ key: 'comisiones', label: 'Comisiones' },
|
||||
{ key: 'pagos-servicios', label: 'Pagos · Servicios' },
|
||||
{ key: 'pagos-clientes', label: 'Pagos · Clientes' },
|
||||
{ key: 'devoluciones', label: 'Devoluciones' },
|
||||
{ key: 'top-clientes', label: 'Top clientes' },
|
||||
{ key: 'exportar', label: 'Exportar' },
|
||||
{ key: 'horas-agenda', label: 'Horas agenda' },
|
||||
{ key: 'paquetes', label: 'Paquetes' },
|
||||
{ key: 'vendedores', label: 'Vendedores' },
|
||||
{ key: 'concentrado', label: 'Concentrado' },
|
||||
{ key: 'recomendaciones', label: 'Recomendaciones' },
|
||||
{ key: 'kpis', label: 'KPIs' },
|
||||
] as const;
|
||||
|
||||
interface SalesReport {
|
||||
total_sales: number;
|
||||
total_paid: number;
|
||||
@@ -39,6 +78,9 @@ interface CashReport {
|
||||
}
|
||||
|
||||
const Reportes: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'general';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [start, setStart] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
@@ -55,6 +97,7 @@ const Reportes: FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'general') return;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -89,7 +132,7 @@ const Reportes: FC = () => {
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [start, end, today]);
|
||||
}, [start, end, today, tab]);
|
||||
|
||||
const aptData = appointmentsReport
|
||||
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
|
||||
@@ -98,54 +141,107 @@ const Reportes: FC = () => {
|
||||
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
const exportCSV = () => {
|
||||
const rows: (string | number)[][] = [];
|
||||
rows.push(['Reporte SKEEN']);
|
||||
rows.push(['Rango', `${start} a ${end}`]);
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Ventas']);
|
||||
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
|
||||
if (salesReport) {
|
||||
rows.push([
|
||||
salesReport.total_sales,
|
||||
salesReport.total_paid,
|
||||
salesReport.total_due,
|
||||
salesReport.count,
|
||||
]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportExcel = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
if (salesReport) {
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Total', cantidad: null, monto: salesReport.total_sales });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Pagado', cantidad: null, monto: salesReport.total_paid });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: salesReport.total_due });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Cantidad', cantidad: salesReport.count, monto: null });
|
||||
}
|
||||
aptData.forEach((row) => rows.push({ seccion: 'Citas por estado', concepto: row.name, cantidad: row.value, monto: null }));
|
||||
cashData.forEach((row) => rows.push({ seccion: 'Efectivo por método', concepto: row.name, cantidad: null, monto: row.value }));
|
||||
commissions.forEach((c) =>
|
||||
rows.push({ seccion: 'Comisiones', concepto: c.doctor, cantidad: c.items, monto: c.commission }));
|
||||
await exportToExcel({
|
||||
filename: `reporte-skeen-${start}_${end}`,
|
||||
sheetName: 'General',
|
||||
title: 'Reporte general SKEEN',
|
||||
subtitle: `Rango ${start} a ${end} · efectivo del ${today}`,
|
||||
columns: [
|
||||
{ header: 'Sección', key: 'seccion' },
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
|
||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||
],
|
||||
rows,
|
||||
totals: { seccion: '', concepto: 'Comisión total', monto: commTotals.commission },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Citas por estado']);
|
||||
rows.push(['Estado', 'Cantidad']);
|
||||
aptData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Efectivo por método']);
|
||||
rows.push(['Método', 'Monto']);
|
||||
cashData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Comisiones por médico']);
|
||||
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
|
||||
commissions.forEach((c) =>
|
||||
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
|
||||
);
|
||||
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
|
||||
|
||||
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
|
||||
toast.success('CSV descargado');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
|
||||
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
{tab === 'general' && (
|
||||
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Tabs de reportes */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'diario' ? (
|
||||
<DailyReport />
|
||||
) : tab === 'cortes' ? (
|
||||
<CortesReport />
|
||||
) : tab === 'ingresos' ? (
|
||||
<IngresosReport />
|
||||
) : tab === 'inventario' ? (
|
||||
<InventarioReport />
|
||||
) : tab === 'adeudos' ? (
|
||||
<AdeudosReport />
|
||||
) : tab === 'comisiones' ? (
|
||||
<ComisionesReport />
|
||||
) : tab === 'pagos-servicios' ? (
|
||||
<PagosServiciosReport />
|
||||
) : tab === 'pagos-clientes' ? (
|
||||
<PagosClientesReport />
|
||||
) : tab === 'devoluciones' ? (
|
||||
<DevolucionesReport />
|
||||
) : tab === 'top-clientes' ? (
|
||||
<TopClientesReport />
|
||||
) : tab === 'exportar' ? (
|
||||
<ExportarReport />
|
||||
) : tab === 'horas-agenda' ? (
|
||||
<HorasAgendaReport />
|
||||
) : tab === 'paquetes' ? (
|
||||
<PaquetesReport />
|
||||
) : tab === 'vendedores' ? (
|
||||
<VendedoresReport />
|
||||
) : tab === 'concentrado' ? (
|
||||
<ConcentradoReport />
|
||||
) : tab === 'recomendaciones' ? (
|
||||
<RecomendacionesReport />
|
||||
) : tab === 'kpis' ? (
|
||||
<KpisReport />
|
||||
) : (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||
@@ -295,6 +391,8 @@ const Reportes: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star, Edit2, Check, X, Plus, Clock, Tag, Package } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Star, Edit2, Plus, Clock, Tag, Package, Search } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
@@ -13,40 +14,86 @@ import {
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'Todas las categorías' },
|
||||
{ value: 'consulta', label: 'Consulta' },
|
||||
{ value: 'tratamiento', label: 'Tratamiento' },
|
||||
{ value: 'procedimiento', label: 'Procedimiento' },
|
||||
{ value: 'paquete', label: 'Paquete' },
|
||||
];
|
||||
|
||||
const categoryFormOptions = categoryOptions.filter((o) => o.value !== '');
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
consulta: 'Consulta',
|
||||
tratamiento: 'Tratamiento',
|
||||
procedimiento: 'Procedimiento',
|
||||
paquete: 'Paquete',
|
||||
};
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
price: string;
|
||||
package_price: string;
|
||||
package_sessions: string;
|
||||
package_notes: string;
|
||||
duration_min: string;
|
||||
description: string;
|
||||
color: string;
|
||||
service_group: string;
|
||||
}
|
||||
|
||||
const emptyForm: ServiceForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
category: 'tratamiento',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '30',
|
||||
description: '',
|
||||
color: '#EDA588',
|
||||
service_group: '',
|
||||
};
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState(() => searchParams.get('q') || '');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const pageSize = 60;
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Service | null>(null);
|
||||
const [editPrice, setEditPrice] = useState('');
|
||||
const [editPackagePrice, setEditPackagePrice] = useState('');
|
||||
const [editPackageSessions, setEditPackageSessions] = useState('');
|
||||
const [editPackageNotes, setEditPackageNotes] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<ServiceForm>(emptyForm);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
code: '',
|
||||
category: '',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '',
|
||||
description: '',
|
||||
color: '#78716c',
|
||||
service_group: '',
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getServices();
|
||||
if (res.status === 'success') setServices(res.services);
|
||||
const res = await odooApi.getServices({
|
||||
search: search || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setServices(res.services);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar servicios');
|
||||
toast.error('Error al cargar servicios');
|
||||
@@ -54,11 +101,16 @@ const Servicios: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [search, categoryFilter, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
const t = setTimeout(load, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, categoryFilter]);
|
||||
|
||||
const toggleFavorite = async (s: Service) => {
|
||||
try {
|
||||
@@ -71,81 +123,93 @@ const Servicios: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (s: Service) => {
|
||||
setEditing(s);
|
||||
setEditPrice(String(s.price));
|
||||
setEditPackagePrice(String(s.package_price ?? ''));
|
||||
setEditPackageSessions(String(s.package_sessions ?? ''));
|
||||
setEditPackageNotes(s.package_notes || '');
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const savePrice = async () => {
|
||||
const openEdit = (s: Service) => {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
code: s.code || '',
|
||||
category: s.category || 'tratamiento',
|
||||
price: String(s.price),
|
||||
package_price: s.package_price ? String(s.package_price) : '',
|
||||
package_sessions: s.package_sessions ? String(s.package_sessions) : '',
|
||||
package_notes: s.package_notes || '',
|
||||
duration_min: String(s.duration_min),
|
||||
description: s.description || '',
|
||||
color: s.color || '#EDA588',
|
||||
service_group: s.service_group || '',
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const deactivate = async () => {
|
||||
if (!editing) return;
|
||||
const price = parseFloat(editPrice);
|
||||
const packagePrice = editPackagePrice === '' ? 0 : parseFloat(editPackagePrice);
|
||||
const packageSessions = editPackageSessions === '' ? 0 : parseInt(editPackageSessions, 10);
|
||||
if (Number.isNaN(price) || price < 0) {
|
||||
toast.error('Precio inválido');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`¿Desactivar "${editing.name}"? Dejará de aparecer en el catálogo y en los selectores de citas/ventas.`)) return;
|
||||
try {
|
||||
await odooApi.updateService(editing.id, {
|
||||
price,
|
||||
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
|
||||
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
|
||||
package_notes: editPackageNotes,
|
||||
});
|
||||
toast.success('Servicio actualizado');
|
||||
setSubmitting(true);
|
||||
await odooApi.updateService(editing.id, { active: false } as Partial<Service>);
|
||||
toast.success('Servicio desactivado');
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar servicio');
|
||||
toast.error('Error al desactivar servicio');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
const save = async () => {
|
||||
const price = parseFloat(form.price);
|
||||
const duration = parseInt(form.duration_min, 10);
|
||||
const packagePrice = form.package_price === '' ? 0 : parseFloat(form.package_price);
|
||||
const packageSessions = form.package_sessions === '' ? 0 : parseInt(form.package_sessions, 10);
|
||||
if (!form.name || Number.isNaN(price) || price < 0) {
|
||||
toast.error('Nombre y precio válidos son obligatorios');
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(price) || price < 0) {
|
||||
toast.error('Precio inválido');
|
||||
return;
|
||||
}
|
||||
if (form.category === 'paquete' && (Number.isNaN(packageSessions) || packageSessions <= 0)) {
|
||||
toast.error('Un paquete debe tener al menos 1 sesión');
|
||||
return;
|
||||
}
|
||||
const payload: Partial<Service> = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
category: form.category,
|
||||
price,
|
||||
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
|
||||
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
|
||||
package_notes: form.package_notes,
|
||||
duration_min: Number.isNaN(duration) ? 30 : duration,
|
||||
description: form.description,
|
||||
color: form.color,
|
||||
service_group: form.service_group,
|
||||
};
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createService({
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
category: form.category,
|
||||
price,
|
||||
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
|
||||
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
|
||||
package_notes: form.package_notes,
|
||||
duration_min: Number.isNaN(duration) ? 0 : duration,
|
||||
description: form.description,
|
||||
color: form.color,
|
||||
service_group: form.service_group,
|
||||
});
|
||||
toast.success('Servicio creado');
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
name: '',
|
||||
code: '',
|
||||
category: '',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '',
|
||||
description: '',
|
||||
color: '#78716c',
|
||||
service_group: '',
|
||||
});
|
||||
if (editing) {
|
||||
await odooApi.updateService(editing.id, payload);
|
||||
toast.success('Servicio actualizado');
|
||||
} else {
|
||||
await odooApi.createService(payload);
|
||||
toast.success('Servicio creado');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear servicio');
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || (editing ? 'Error al actualizar servicio' : 'Error al crear servicio'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -158,12 +222,30 @@ const Servicios: FC = () => {
|
||||
return (
|
||||
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o código..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
options={categoryOptions}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="sm:max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
@@ -173,93 +255,60 @@ const Servicios: FC = () => {
|
||||
) : services.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle="No hay servicios registrados."
|
||||
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'}
|
||||
actionLabel="Nuevo servicio"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((s) => (
|
||||
<Card key={s.id} className="hover:shadow-md transition">
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color || '#78716c' }}
|
||||
/>
|
||||
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((s) => (
|
||||
<Card key={s.id} className="hover:shadow-md transition">
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color || '#78716c' }}
|
||||
/>
|
||||
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleFavorite(s)}
|
||||
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
|
||||
fill={s.is_favorite ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{categoryLabels[s.category] || s.category || 'Sin categoría'}
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Clock size={12} className="mr-1" />
|
||||
{s.duration_min} min
|
||||
</span>
|
||||
{s.code && (
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
{s.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleFavorite(s)}
|
||||
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
|
||||
fill={s.is_favorite ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{s.category || 'Sin categoría'}
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Clock size={12} className="mr-1" />
|
||||
{s.duration_min} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing?.id === s.id ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Precio"
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Precio paquete"
|
||||
type="number"
|
||||
value={editPackagePrice}
|
||||
onChange={(e) => setEditPackagePrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Sesiones"
|
||||
type="number"
|
||||
value={editPackageSessions}
|
||||
onChange={(e) => setEditPackageSessions(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Notas paquete"
|
||||
value={editPackageNotes}
|
||||
onChange={(e) => setEditPackageNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={savePrice} title="Guardar">
|
||||
<Check size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(null)} title="Cancelar">
|
||||
<X size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Precio sesión</p>
|
||||
<p className="text-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(s)} title="Editar">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -275,22 +324,43 @@ const Servicios: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Paginación */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
|
||||
<p className="text-sm text-theme-muted">
|
||||
{total} servicios · página {page} de {totalPages || 1}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal crear/editar */}
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nuevo servicio"
|
||||
maxWidth="md"
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditing(null); }}
|
||||
title={editing ? `Editar: ${editing.name}` : 'Nuevo servicio'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Crear servicio</Button>
|
||||
{editing && (
|
||||
<Button variant="danger" onClick={deactivate} loading={submitting} className="sm:mr-auto">
|
||||
Desactivar
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); }}>Cancelar</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear servicio'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -305,9 +375,11 @@ const Servicios: FC = () => {
|
||||
label="Código"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
placeholder="Ej. DEP-AXI"
|
||||
/>
|
||||
<Input
|
||||
label="Categoría"
|
||||
<Select
|
||||
label="Categoría *"
|
||||
options={categoryFormOptions}
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
/>
|
||||
@@ -345,17 +417,28 @@ const Servicios: FC = () => {
|
||||
value={form.package_notes}
|
||||
onChange={(e) => setForm({ ...form, package_notes: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Color"
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Grupo"
|
||||
value={form.service_group}
|
||||
onChange={(e) => setForm({ ...form, service_group: e.target.value })}
|
||||
placeholder="Ej. Faciales, Corporales"
|
||||
/>
|
||||
<Input
|
||||
label="Color"
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Descripción"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Si el nombre o código ya existen en el catálogo, el sistema lo rechazará para evitar duplicados.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
|
||||
324
frontend/src/pages/UltimasVisitas.tsx
Normal file
324
frontend/src/pages/UltimasVisitas.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, FolderClock, FileText, Images, StickyNote } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
MobileCard,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type ExpedienteReciente, type Doctor } from '../services/odoo';
|
||||
|
||||
const UltimasVisitas: FC = () => {
|
||||
const [expedientes, setExpedientes] = useState<ExpedienteReciente[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recencia, setRecencia] = useState<'' | '6m' | '6m_plus'>('');
|
||||
const [onlyVip, setOnlyVip] = useState(false);
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [modalAdjuntos, setModalAdjuntos] = useState<{ patient: ExpedienteReciente; kind: 'expediente' | 'imagen' } | null>(null);
|
||||
const [modalNotas, setModalNotas] = useState<ExpedienteReciente | null>(null);
|
||||
const [notas, setNotas] = useState('');
|
||||
const [savingNotas, setSavingNotas] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = { page, page_size: 50 };
|
||||
if (recencia) {
|
||||
params.recencia = recencia;
|
||||
} else {
|
||||
// Sin chip de recencia: todos los pacientes con visita, más reciente primero
|
||||
params.date_from = 'todas';
|
||||
}
|
||||
if (onlyVip) params.vip = '1';
|
||||
if (doctorFilter) params.doctor_id = doctorFilter;
|
||||
if (search) params.search = search;
|
||||
const res = await odooApi.getExpedientesRecientes(params);
|
||||
if (res.status === 'success') {
|
||||
setExpedientes(res.expedientes);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar expedientes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [recencia, onlyVip, doctorFilter, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [recencia, onlyVip, doctorFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const openNotas = (p: ExpedienteReciente) => {
|
||||
setModalNotas(p);
|
||||
setNotas(p.internal_notes || '');
|
||||
};
|
||||
|
||||
const saveNotas = async () => {
|
||||
if (!modalNotas) return;
|
||||
try {
|
||||
setSavingNotas(true);
|
||||
await odooApi.updatePatient(modalNotas.id, { internal_notes: notas });
|
||||
toast.success('Notas actualizadas');
|
||||
setModalNotas(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al guardar notas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingNotas(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Expedientes" subtitle="Expedientes y actividad de pacientes">
|
||||
<PageHeader title="Expedientes" subtitle="Expedientes, galería y notas de los pacientes" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros rápidos */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||
{([
|
||||
{ key: '6m', label: 'Visita últimos 6 meses' },
|
||||
{ key: '6m_plus', label: '+6 meses sin visita' },
|
||||
] as const).map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => setRecencia(recencia === chip.key ? '' : chip.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
recencia === chip.key
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOnlyVip(!onlyVip)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
onlyVip
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
VIP
|
||||
</button>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Todos los médicos' },
|
||||
...doctors.map((d) => ({
|
||||
value: String(d.id),
|
||||
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
|
||||
})),
|
||||
]}
|
||||
value={doctorFilter}
|
||||
onChange={(e) => setDoctorFilter(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
{(recencia || onlyVip || doctorFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRecencia(''); setOnlyVip(false); setDoctorFilter(''); }}
|
||||
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||
>
|
||||
Quitar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : expedientes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin visitas recientes"
|
||||
subtitle="No hay expedientes que coincidan con los filtros seleccionados."
|
||||
icon={<FolderClock size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha de visita</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Cliente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Edad</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Médico asignado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Exp. escaneado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Galería</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Notas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{expedientes.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted whitespace-nowrap">{p.last_visit || '-'}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<p className="font-medium text-theme-heading">{p.name}</p>
|
||||
<p className="text-xs text-theme-muted">{p.patient_id}</p>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.age ?? '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.phone || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.primary_doctor || '-'}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
|
||||
<FileText size={14} className="mr-1.5" />
|
||||
{p.expediente_count > 0 ? `Ver (${p.expediente_count})` : 'Subir'}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
|
||||
<Images size={14} className="mr-1.5" />
|
||||
{p.imagenes_count} fotos
|
||||
</Button>
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNotas(p)}
|
||||
className="text-left text-theme-muted hover:text-theme-heading max-w-[180px] truncate block"
|
||||
title={p.internal_notes || 'Agregar notas'}
|
||||
>
|
||||
{p.internal_notes ? p.internal_notes : <StickyNote size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden space-y-3">
|
||||
{expedientes.map((p) => (
|
||||
<MobileCard
|
||||
key={p.id}
|
||||
title={p.name}
|
||||
subtitle={`${p.last_visit || '-'} · ${p.patient_id}`}
|
||||
rows={[
|
||||
{ label: 'Edad', value: p.age ?? '-' },
|
||||
{ label: 'Teléfono', value: p.phone || '-' },
|
||||
{ label: 'Médico', value: p.primary_doctor || '-' },
|
||||
{ label: 'Notas', value: p.internal_notes ? p.internal_notes.slice(0, 40) : '-' },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" title="Expediente escaneado" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
|
||||
<FileText size={16} className="text-theme-muted" />
|
||||
{p.expediente_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.expediente_count}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="Galería" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
|
||||
<Images size={16} className="text-theme-muted" />
|
||||
{p.imagenes_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.imagenes_count}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="Notas" onClick={() => openNotas(p)}>
|
||||
<StickyNote size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Paginación */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
|
||||
<p className="text-sm text-theme-muted">
|
||||
{total} pacientes · página {page} de {totalPages || 1}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal de adjuntos (expediente escaneado o galería) */}
|
||||
<Modal
|
||||
isOpen={!!modalAdjuntos}
|
||||
onClose={() => setModalAdjuntos(null)}
|
||||
title={modalAdjuntos
|
||||
? `${modalAdjuntos.kind === 'expediente' ? 'Expediente escaneado' : 'Galería'} — ${modalAdjuntos.patient.name}`
|
||||
: 'Archivos'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{modalAdjuntos && (
|
||||
<PatientAdjuntos
|
||||
patientId={modalAdjuntos.patient.id}
|
||||
kind={modalAdjuntos.kind}
|
||||
onChange={load}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal de notas */}
|
||||
<Modal
|
||||
isOpen={!!modalNotas}
|
||||
onClose={() => setModalNotas(null)}
|
||||
title={modalNotas ? `Notas — ${modalNotas.name}` : 'Notas'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalNotas(null)}>Cancelar</Button>
|
||||
<Button onClick={saveNotas} loading={savingNotas}>Guardar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
label="Notas internas (solo equipo)"
|
||||
value={notas}
|
||||
onChange={(e) => setNotas(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default UltimasVisitas;
|
||||
@@ -1,265 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Pencil, KeyRound } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
||||
import { ROLE_LABELS } from '../lib/auth';
|
||||
|
||||
const roleOptions: { value: FrontendRole; label: string }[] = (
|
||||
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
|
||||
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||
|
||||
const roleBadge = (role: FrontendRole) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'danger';
|
||||
case 'recepcion':
|
||||
return 'info';
|
||||
case 'medico':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const Usuarios: FC = () => {
|
||||
const [users, setUsers] = useState<FrontendUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FrontendUser | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.listFrontendUsers();
|
||||
if (res.status === 'success') setUsers(res.users);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar usuarios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (u: FrontendUser) => {
|
||||
setEditing(u);
|
||||
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!editing && !form.login.trim()) {
|
||||
toast.error('El usuario es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!editing && form.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
if (editing && form.password && form.password.length < 8) {
|
||||
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateFrontendUser(editing.id, {
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
toast.success('Usuario actualizado');
|
||||
} else {
|
||||
await odooApi.createFrontendUser({
|
||||
login: form.login.trim(),
|
||||
name: form.name.trim(),
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
});
|
||||
toast.success('Usuario creado');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar usuario');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (u: FrontendUser) => {
|
||||
try {
|
||||
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al cambiar estado');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Usuarios" subtitle="Cuentas y roles del frontend">
|
||||
<PageHeader title="Usuarios" subtitle="Gestiona quién puede entrar al sistema y su rol">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-12 w-full" />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Usuario</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Último acceso</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
|
||||
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{u.last_login || '—'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
|
||||
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-theme-heading'} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{users.map((u) => (
|
||||
<MobileCard
|
||||
key={u.id}
|
||||
title={u.name}
|
||||
subtitle={u.login}
|
||||
rows={[
|
||||
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-theme-muted" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Usuario (login)"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
disabled={!!editing}
|
||||
placeholder="ej. recepcion1"
|
||||
/>
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Nombre completo"
|
||||
/>
|
||||
<Select
|
||||
label="Rol"
|
||||
options={roleOptions}
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
|
||||
/>
|
||||
<Input
|
||||
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
minLength={8}
|
||||
/>
|
||||
{editing && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Usuario activo
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Usuarios;
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
badgeForSaleState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
interface LineForm {
|
||||
service_id: string;
|
||||
@@ -246,19 +246,40 @@ const Ventas: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportSales = () => {
|
||||
const rows: (string | number)[][] = [
|
||||
['Referencia', 'Paciente', 'Fecha', 'Subtotal', 'Descuento', 'Impuesto', 'Total', 'Pagado', 'Por cobrar', 'Estado', 'Devuelta', 'Monto devuelto'],
|
||||
];
|
||||
sales.forEach((s) =>
|
||||
rows.push([
|
||||
s.name, s.patient, s.date, s.subtotal, s.discount, s.tax, s.total,
|
||||
s.amount_paid, s.amount_due, s.state, s.refunded ? 'Sí' : 'No', s.refund_amount || 0,
|
||||
])
|
||||
);
|
||||
rows.push(['', '', 'Totales', '', '', '', sales.reduce((a, s) => a + s.total, 0), sales.reduce((a, s) => a + s.amount_paid, 0), sales.reduce((a, s) => a + s.amount_due, 0), '', '', sales.reduce((a, s) => a + (s.refund_amount || 0), 0)]);
|
||||
downloadCsv(`ventas-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||
toast.success(`CSV descargado (${sales.length} ventas de la vista actual)`);
|
||||
const exportSales = async () => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `ventas-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Ventas',
|
||||
title: 'Ventas SKEEN',
|
||||
subtitle: `${sales.length} ventas de la vista actual`,
|
||||
columns: [
|
||||
{ header: 'Referencia', key: 'name' },
|
||||
{ header: 'Paciente', key: 'patient' },
|
||||
{ header: 'Fecha', key: 'date', format: 'date' },
|
||||
{ header: 'Subtotal', key: 'subtotal', format: 'currency' },
|
||||
{ header: 'Descuento', key: 'discount', format: 'currency' },
|
||||
{ header: 'Impuesto', key: 'tax', format: 'currency' },
|
||||
{ header: 'Total', key: 'total', format: 'currency' },
|
||||
{ header: 'Pagado', key: 'amount_paid', format: 'currency' },
|
||||
{ header: 'Por cobrar', key: 'amount_due', format: 'currency' },
|
||||
{ header: 'Estado', key: 'state' },
|
||||
{ header: 'Devuelta', key: 'devuelta' },
|
||||
{ header: 'Monto devuelto', key: 'refund_amount', format: 'currency' },
|
||||
],
|
||||
rows: sales.map((s) => ({ ...s, devuelta: s.refunded ? 'Sí' : 'No', refund_amount: s.refund_amount || 0 })),
|
||||
totals: {
|
||||
name: '', total: sales.reduce((a, s) => a + s.total, 0),
|
||||
amount_paid: sales.reduce((a, s) => a + s.amount_paid, 0),
|
||||
amount_due: sales.reduce((a, s) => a + s.amount_due, 0),
|
||||
refund_amount: sales.reduce((a, s) => a + (s.refund_amount || 0), 0),
|
||||
},
|
||||
});
|
||||
toast.success(`Excel descargado (${sales.length} ventas de la vista actual)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
|
||||
614
frontend/src/pages/Visitas.tsx
Normal file
614
frontend/src/pages/Visitas.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { ClipboardList, CheckCircle2, XCircle, X, Plus, Camera, FileText } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
MobileCard,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
completada: 'Completada',
|
||||
cancelada: 'Cancelada',
|
||||
};
|
||||
|
||||
const badgeForVisitaState = (state: string): BadgeVariant =>
|
||||
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
|
||||
|
||||
const estadoOptions = [
|
||||
{ value: '', label: 'Todos' },
|
||||
{ value: 'en_curso', label: 'En curso' },
|
||||
{ value: 'completada', label: 'Completada' },
|
||||
{ value: 'cancelada', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
const adjuntoKindLabels: Record<string, string> = {
|
||||
antes: 'Antes',
|
||||
despues: 'Después',
|
||||
documento: 'Documento',
|
||||
};
|
||||
|
||||
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
const [estado, setEstado] = useState('');
|
||||
const [doctorFiltro, setDoctorFiltro] = useState('');
|
||||
const [selected, setSelected] = useState<Visita | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
motivo: '',
|
||||
diagnostico: '',
|
||||
tratamiento: '',
|
||||
notas: '',
|
||||
receta_text: '',
|
||||
doctor_id: '',
|
||||
cosmetologa_id: '',
|
||||
});
|
||||
const [printOpen, setPrintOpen] = useState(false);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [itemSearch, setItemSearch] = useState('');
|
||||
const [nuevoItem, setNuevoItem] = useState('');
|
||||
const [nuevaQty, setNuevaQty] = useState('1');
|
||||
const [visor, setVisor] = useState<VisitaAdjunto | null>(null);
|
||||
const fileAntesRef = useRef<HTMLInputElement>(null);
|
||||
const fileDespuesRef = useRef<HTMLInputElement>(null);
|
||||
const fileDocRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const doctorOptions = [
|
||||
{ value: '', label: 'Sin asignar' },
|
||||
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
||||
];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = {};
|
||||
if (fecha) params.date = fecha;
|
||||
if (estado) params.state = estado;
|
||||
if (doctorFiltro) params.doctor_id = doctorFiltro;
|
||||
const res = await odooApi.getVisitas(params);
|
||||
if (res.status === 'success') setVisitas(res.visitas);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar visitas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fecha, estado, doctorFiltro]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => {
|
||||
if (res.status === 'success') setDoctors(res.doctors);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Catálogo de artículos del inventario para el picker de insumos
|
||||
useEffect(() => {
|
||||
if (!selected || selected.state !== 'en_curso') return;
|
||||
odooApi.getInventarioItems(itemSearch || undefined)
|
||||
.then((res) => {
|
||||
if (res.status === 'success') setItems(res.items);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [selected, itemSearch]);
|
||||
|
||||
const itemOptions = [
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((it) => ({ value: String(it.id), label: `${it.name} (${it.qty} ${it.unit})` })),
|
||||
];
|
||||
|
||||
const agregarInsumo = async () => {
|
||||
if (!selected || !nuevoItem) return;
|
||||
const qty = parseFloat(nuevaQty);
|
||||
if (Number.isNaN(qty) || qty <= 0) {
|
||||
toast.error('La cantidad debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.addVisitaInsumo(selected.id, { item_id: parseInt(nuevoItem, 10), qty });
|
||||
if (res.status === 'success') {
|
||||
applyUpdate(res.visita);
|
||||
setNuevoItem('');
|
||||
setNuevaQty('1');
|
||||
toast.success('Insumo agregado');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al agregar insumo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quitarInsumo = async (lineId: number) => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.deleteVisitaInsumo(selected.id, lineId);
|
||||
if (res.status === 'success') applyUpdate(res.visita);
|
||||
} catch (err) {
|
||||
toast.error('Error al quitar insumo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subirAdjunto = (kind: 'antes' | 'despues' | 'documento') => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !selected) return;
|
||||
if (file.size > MAX_ADJUNTO_BYTES) {
|
||||
toast.error('El archivo excede 10 MB');
|
||||
return;
|
||||
}
|
||||
const visitaId = selected.id;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const file_b64 = String(reader.result || '').split(',')[1] || '';
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.uploadVisitaAdjunto(visitaId, {
|
||||
kind,
|
||||
name: file.name,
|
||||
file_b64,
|
||||
mimetype: file.type || 'application/octet-stream',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
applyUpdate(res.visita);
|
||||
toast.success('Archivo subido');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al subir el archivo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const borrarAdjunto = async (adjId: number) => {
|
||||
if (!window.confirm('¿Eliminar este archivo?')) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.deleteVisitaAdjunto(adjId);
|
||||
if (res.status === 'success') applyUpdate(res.visita);
|
||||
} catch (err) {
|
||||
toast.error('Error al eliminar el archivo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = (v: Visita) => {
|
||||
setSelected(v);
|
||||
setForm({
|
||||
motivo: v.motivo || '',
|
||||
diagnostico: v.diagnostico || '',
|
||||
tratamiento: v.tratamiento || '',
|
||||
notas: v.notas || '',
|
||||
receta_text: v.receta_text || '',
|
||||
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
|
||||
cosmetologa_id: v.cosmetologa_id ? String(v.cosmetologa_id) : '',
|
||||
});
|
||||
};
|
||||
|
||||
const updateForm = (patch: Partial<typeof form>) => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const applyUpdate = (v: Visita) => {
|
||||
setSelected(v);
|
||||
setVisitas((prev) => prev.map((it) => (it.id === v.id ? v : it)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.updateVisita(selected.id, {
|
||||
motivo: form.motivo,
|
||||
diagnostico: form.diagnostico,
|
||||
tratamiento: form.tratamiento,
|
||||
notas: form.notas,
|
||||
receta_text: form.receta_text,
|
||||
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
|
||||
cosmetologa_id: form.cosmetologa_id ? parseInt(form.cosmetologa_id, 10) : null,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
applyUpdate(res.visita);
|
||||
toast.success('Visita actualizada');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al guardar la visita');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completar = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.completeVisita(selected.id);
|
||||
if (res.status === 'success') {
|
||||
applyUpdate(res.visita);
|
||||
toast.success('Visita completada');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al completar la visita');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelar = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.cancelVisita(selected.id);
|
||||
if (res.status === 'success') {
|
||||
applyUpdate(res.visita);
|
||||
toast.success('Visita cancelada');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cancelar la visita');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
|
||||
|
||||
return (
|
||||
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
|
||||
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
|
||||
<Select label="Estado" options={estadoOptions} value={estado} onChange={(e) => setEstado(e.target.value)} />
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
|
||||
value={doctorFiltro}
|
||||
onChange={(e) => setDoctorFiltro(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : visitas.length === 0 ? (
|
||||
<EmptyState
|
||||
title={fecha === hoy() ? 'Sin visitas hoy' : 'Sin visitas'}
|
||||
subtitle="No se encontraron visitas con los filtros seleccionados."
|
||||
icon={<ClipboardList size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<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 md:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{visitas.map((v) => (
|
||||
<tr key={v.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDetail(v)}>
|
||||
<td className="p-3 text-sm text-theme-muted">{hora(v)}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{v.patient}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.servicio || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.doctor || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell truncate max-w-[200px]">{v.motivo || '-'}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<Badge variant={badgeForVisitaState(v.state)}>
|
||||
{visitaStateLabels[v.state] || v.state}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{visitas.map((v) => (
|
||||
<MobileCard
|
||||
key={v.id}
|
||||
title={v.patient}
|
||||
subtitle={hora(v)}
|
||||
rows={[
|
||||
{ label: 'Servicio', value: v.servicio || '-' },
|
||||
{ label: 'Médico', value: v.doctor || '-' },
|
||||
{
|
||||
label: 'Estado',
|
||||
value: (
|
||||
<Badge variant={badgeForVisitaState(v.state)}>
|
||||
{visitaStateLabels[v.state] || v.state}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
onClick={() => openDetail(v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal detalle/edición */}
|
||||
<Modal
|
||||
isOpen={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
title={selected ? `Visita ${selected.name} — ${selected.patient}` : 'Visita'}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
|
||||
{selected?.state === 'en_curso' && (
|
||||
<Button variant="outline" onClick={cancelar} loading={submitting}>
|
||||
<XCircle size={16} className="mr-2" />
|
||||
Cancelar visita
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={save} loading={submitting}>Guardar</Button>
|
||||
{selected?.state === 'en_curso' && (
|
||||
<Button onClick={completar} loading={submitting}>
|
||||
<CheckCircle2 size={16} className="mr-2" />
|
||||
Completar visita
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selected && (
|
||||
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForVisitaState(selected.state)}>
|
||||
{visitaStateLabels[selected.state] || selected.state}
|
||||
</Badge>
|
||||
<span className="text-sm text-theme-muted">
|
||||
{selected.date_start || '-'}{selected.date_end ? ` → ${selected.date_end}` : ''}
|
||||
</span>
|
||||
{selected.servicio && (
|
||||
<span className="text-sm text-theme-muted">· {selected.servicio}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Médico"
|
||||
options={doctorOptions}
|
||||
value={form.doctor_id}
|
||||
onChange={(e) => updateForm({ doctor_id: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Cosmetóloga"
|
||||
options={doctorOptions}
|
||||
value={form.cosmetologa_id}
|
||||
onChange={(e) => updateForm({ cosmetologa_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Input label="Motivo de consulta" value={form.motivo} onChange={(e) => updateForm({ motivo: e.target.value })} />
|
||||
<TextArea label="Diagnóstico" value={form.diagnostico} onChange={(e) => updateForm({ diagnostico: e.target.value })} />
|
||||
<TextArea label="Tratamiento" value={form.tratamiento} onChange={(e) => updateForm({ tratamiento: e.target.value })} />
|
||||
<TextArea label="Notas" value={form.notas} onChange={(e) => updateForm({ notas: e.target.value })} />
|
||||
|
||||
<RecetaSection
|
||||
value={form.receta_text}
|
||||
onChange={(v) => updateForm({ receta_text: v })}
|
||||
onPrint={() => setPrintOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Insumos usados */}
|
||||
<section className="border-t border-theme-border pt-4">
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3">Insumos usados</h4>
|
||||
{selected.insumos_descargados && (
|
||||
<p className="text-xs text-theme-muted mb-2">Insumos descargados del inventario.</p>
|
||||
)}
|
||||
{selected.insumos.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted mb-3">Sin insumos registrados.</p>
|
||||
) : (
|
||||
<ul className="space-y-1 mb-3">
|
||||
{selected.insumos.map((l) => (
|
||||
<li key={l.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">
|
||||
{l.item}
|
||||
{l.notes && <span className="block text-xs text-theme-muted">{l.notes}</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-theme-muted">{l.qty} {l.unit}</span>
|
||||
{selected.state === 'en_curso' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => quitarInsumo(l.id)} title="Quitar" disabled={submitting}>
|
||||
<X size={14} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{selected.state === 'en_curso' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
options={itemOptions}
|
||||
value={nuevoItem}
|
||||
onChange={(e) => setNuevoItem(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<Input
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={nuevaQty}
|
||||
onChange={(e) => setNuevaQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={agregarInsumo} disabled={!nuevoItem} loading={submitting}>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Agregar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Fotos y documentos */}
|
||||
<section className="border-t border-theme-border pt-4">
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3">Fotos y documentos</h4>
|
||||
{selected.state === 'en_curso' && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<input ref={fileAntesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('antes')} />
|
||||
<input ref={fileDespuesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('despues')} />
|
||||
<input ref={fileDocRef} type="file" className="hidden" onChange={subirAdjunto('documento')} />
|
||||
<Button variant="outline" size="sm" onClick={() => fileAntesRef.current?.click()} disabled={submitting}>
|
||||
<Camera size={14} className="mr-1.5" /> Foto antes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileDespuesRef.current?.click()} disabled={submitting}>
|
||||
<Camera size={14} className="mr-1.5" /> Foto después
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileDocRef.current?.click()} disabled={submitting}>
|
||||
<FileText size={14} className="mr-1.5" /> Documento
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selected.adjuntos.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin archivos adjuntos.</p>
|
||||
) : (
|
||||
<>
|
||||
{selected.adjuntos.filter((a) => a.kind !== 'documento').length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
||||
{selected.adjuntos.filter((a) => a.kind !== 'documento').map((a) => (
|
||||
<div key={a.id} className="relative">
|
||||
<button type="button" onClick={() => setVisor(a)} className="w-full">
|
||||
<img
|
||||
src={odooApi.visitaAdjuntoUrl(a.url)}
|
||||
alt={a.name}
|
||||
className="w-full h-24 object-cover rounded-xl border border-theme-border"
|
||||
/>
|
||||
</button>
|
||||
<span className="absolute bottom-1 left-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-theme-surface/90 text-theme-heading">
|
||||
{adjuntoKindLabels[a.kind] || a.kind}
|
||||
</span>
|
||||
{selected.state === 'en_curso' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => borrarAdjunto(a.id)}
|
||||
title="Eliminar"
|
||||
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.adjuntos.filter((a) => a.kind === 'documento').length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{selected.adjuntos.filter((a) => a.kind === 'documento').map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
|
||||
<a
|
||||
href={odooApi.visitaAdjuntoUrl(a.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center text-theme-heading hover:underline min-w-0"
|
||||
>
|
||||
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
||||
<span className="truncate">{a.name}</span>
|
||||
</a>
|
||||
{selected.state === 'en_curso' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => borrarAdjunto(a.id)} title="Eliminar" disabled={submitting}>
|
||||
<X size={14} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Visor de foto */}
|
||||
<Modal
|
||||
isOpen={!!visor}
|
||||
onClose={() => setVisor(null)}
|
||||
title={visor ? `${adjuntoKindLabels[visor.kind] || visor.kind} — ${visor.name}` : 'Foto'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{visor && (
|
||||
<img
|
||||
src={odooApi.visitaAdjuntoUrl(visor.url)}
|
||||
alt={visor.name}
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Vista de impresión de la receta */}
|
||||
{selected && (
|
||||
<RecetaPrint
|
||||
isOpen={printOpen}
|
||||
onClose={() => setPrintOpen(false)}
|
||||
paciente={selected.patient}
|
||||
doctor={selected.doctor}
|
||||
contenido={form.receta_text}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Visitas;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
RefreshCw,
|
||||
Target,
|
||||
@@ -13,11 +14,19 @@ import {
|
||||
MoreHorizontal,
|
||||
User,
|
||||
ArrowRightCircle,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, Select, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
|
||||
import { odooApi, type WacrmLead, type WacrmPipeline } from '../services/odoo';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'open', label: 'Abierto' },
|
||||
@@ -66,6 +75,12 @@ const WacrmLeads: FC = () => {
|
||||
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
|
||||
const [stageModalOpen, setStageModalOpen] = useState(false);
|
||||
const [movingStage, setMovingStage] = useState<string | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [filtroAsign, setFiltroAsign] = useState<'todos' | 'sin_asignar' | 'mios'>('todos');
|
||||
const [convirtiendo, setConvirtiendo] = useState(false);
|
||||
const [currentAgentId] = useState(() =>
|
||||
typeof window !== 'undefined' ? localStorage.getItem('skeen_current_agent_id') || '' : ''
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -136,6 +151,46 @@ const WacrmLeads: FC = () => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getWacrmMembers()
|
||||
.then((res) => { if (res.status === 'success') setMembers(res.members); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const assignLead = async (lead: WacrmLead, memberId: string | null) => {
|
||||
try {
|
||||
const res = await odooApi.updateWacrmLeadStatus(lead.id, { assigned_to: memberId });
|
||||
if (res.status === 'success') {
|
||||
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||
setSelectedLead(res.lead);
|
||||
toast.success(memberId ? 'Lead asignado' : 'Lead desasignado');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al asignar lead');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const convertirLead = async (lead: WacrmLead) => {
|
||||
try {
|
||||
setConvirtiendo(true);
|
||||
const res = await odooApi.convertWacrmLead(lead.id);
|
||||
if (res.status === 'success') {
|
||||
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||
setSelectedLead(res.lead);
|
||||
toast.success(res.created ? 'Paciente creado y ligado' : 'Lead ligado a paciente existente');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al convertir lead');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setConvirtiendo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const memberName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
|
||||
|
||||
// Auto-sync cada 15 segundos
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -155,9 +210,12 @@ const WacrmLeads: FC = () => {
|
||||
);
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (!activePipeline) return leads;
|
||||
return leads.filter((l) => l.pipeline_id === activePipeline.id);
|
||||
}, [leads, activePipeline]);
|
||||
let out = leads;
|
||||
if (activePipeline) out = out.filter((l) => l.pipeline_id === activePipeline.id);
|
||||
if (filtroAsign === 'sin_asignar') out = out.filter((l) => !l.assigned_to);
|
||||
if (filtroAsign === 'mios') out = out.filter((l) => !!currentAgentId && l.assigned_to === currentAgentId);
|
||||
return out;
|
||||
}, [leads, activePipeline, filtroAsign, currentAgentId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const open = leads.filter((l) => l.status === 'open');
|
||||
@@ -291,6 +349,20 @@ const WacrmLeads: FC = () => {
|
||||
<div className="flex items-center gap-1.5 text-xs text-theme-muted mb-2">
|
||||
<User size={12} />
|
||||
<span className="truncate">{l.contact_name || l.contact_phone || '—'}</span>
|
||||
{l.assigned_to && (
|
||||
<span className="px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[100px]">
|
||||
{memberName(l.assigned_to) || 'Asignado'}
|
||||
</span>
|
||||
)}
|
||||
{l.partner_id && (
|
||||
<Link
|
||||
to={`/pacientes?q=${encodeURIComponent(l.contact_phone || '')}`}
|
||||
className="px-1.5 py-0.5 bg-theme-success text-theme-heading rounded-full shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Paciente ✓
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-theme-heading">
|
||||
@@ -422,6 +494,24 @@ const WacrmLeads: FC = () => {
|
||||
options={pipelineOptions}
|
||||
className="w-full lg:w-52"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([
|
||||
{ key: 'todos', label: 'Todos' },
|
||||
{ key: 'sin_asignar', label: 'Sin asignar' },
|
||||
{ key: 'mios', label: 'Míos' },
|
||||
] as const).map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFiltroAsign(f.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
|
||||
filtroAsign === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-lg border border-theme-border-strong overflow-hidden">
|
||||
<button
|
||||
@@ -468,31 +558,69 @@ const WacrmLeads: FC = () => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal cambiar etapa */}
|
||||
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title="Mover a etapa">
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{activePipeline?.stages.map((stage) => {
|
||||
const isCurrent = selectedLead?.stage_id === stage.id;
|
||||
return (
|
||||
<button
|
||||
key={stage.external_id}
|
||||
disabled={isCurrent || !!movingStage}
|
||||
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
|
||||
isCurrent
|
||||
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
|
||||
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
|
||||
</div>
|
||||
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Modal detalle del lead */}
|
||||
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title={selectedLead ? `Lead — ${selectedLead.title}` : 'Lead'}>
|
||||
{selectedLead && (
|
||||
<div className="space-y-4">
|
||||
{/* Asignación y acciones */}
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
label="Asignado a"
|
||||
options={[{ value: '', label: 'Sin asignar' }, ...members.map((m) => ({ value: m.id, label: m.name }))]}
|
||||
value={selectedLead.assigned_to || ''}
|
||||
onChange={(e) => assignLead(selectedLead, e.target.value || null)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedLead.partner_id ? (
|
||||
<Link to={`/pacientes?q=${encodeURIComponent(selectedLead.contact_phone || '')}`}>
|
||||
<Badge variant="success">Paciente ✓ — ver expediente</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => convertirLead(selectedLead)} loading={convirtiendo}>
|
||||
<UserPlus size={14} className="mr-1.5" />
|
||||
Convertir en paciente
|
||||
</Button>
|
||||
)}
|
||||
{selectedLead.conversation_id && (
|
||||
<Link to={`/wacrm/messages?conv=${selectedLead.conversation_id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<MessageSquare size={14} className="mr-1.5" />
|
||||
Ver conversación
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mover a etapa */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Mover a etapa</p>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{activePipeline?.stages.map((stage) => {
|
||||
const isCurrent = selectedLead?.stage_id === stage.id;
|
||||
return (
|
||||
<button
|
||||
key={stage.external_id}
|
||||
disabled={isCurrent || !!movingStage}
|
||||
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
|
||||
isCurrent
|
||||
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
|
||||
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
|
||||
</div>
|
||||
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
|
||||
import { odooApi, type WacrmConversation, type WacrmMessage } from '../services/odoo';
|
||||
import { odooApi, type WacrmConversation, type WacrmMessage, type Patient } from '../services/odoo';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -32,6 +33,16 @@ const WacrmMessages: FC = () => {
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [filtro, setFiltro] = useState<'todas' | 'mias' | 'sin_asignar' | 'no_leidas'>('todas');
|
||||
const [searchParams] = useSearchParams();
|
||||
const convParam = searchParams.get('conv');
|
||||
|
||||
// Ficha del paciente ligado al teléfono de la conversación
|
||||
const [patientInfo, setPatientInfo] = useState<Patient | null>(null);
|
||||
const [patientSearched, setPatientSearched] = useState(false);
|
||||
const [crearPacienteOpen, setCrearPacienteOpen] = useState(false);
|
||||
const [nuevoNombre, setNuevoNombre] = useState('');
|
||||
const [creandoPaciente, setCreandoPaciente] = useState(false);
|
||||
|
||||
// Refs para evitar loops y dependency churn
|
||||
const selectedRef = useRef<WacrmConversation | null>(null);
|
||||
@@ -128,6 +139,61 @@ const WacrmMessages: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Preselección por ?conv=<external_id> (deep-link desde Leads)
|
||||
useEffect(() => {
|
||||
if (!convParam || conversations.length === 0) return;
|
||||
const found = conversations.find((c) => c.external_id === convParam);
|
||||
if (found) {
|
||||
userSelectedRef.current = true;
|
||||
setSelected(found);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [convParam, conversations]);
|
||||
|
||||
// Buscar paciente por teléfono al cambiar de conversación
|
||||
useEffect(() => {
|
||||
setPatientInfo(null);
|
||||
setPatientSearched(false);
|
||||
setCrearPacienteOpen(false);
|
||||
if (!selected?.contact_phone) return;
|
||||
const phone = selected.contact_phone;
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
const query = digits.length > 10 ? digits.slice(-10) : digits;
|
||||
odooApi.getPatients({ search: query, page_size: 5 })
|
||||
.then((res) => {
|
||||
if (res.status === 'success') {
|
||||
const match = res.patients.find((p) => (p.phone || '').replace(/\D/g, '').endsWith(digits.slice(-10)))
|
||||
|| res.patients[0];
|
||||
setPatientInfo(match || null);
|
||||
}
|
||||
setPatientSearched(true);
|
||||
})
|
||||
.catch(() => setPatientSearched(true));
|
||||
}, [selected]);
|
||||
|
||||
const crearPaciente = async () => {
|
||||
if (!selected) return;
|
||||
const digits = (selected.contact_phone || '').replace(/\D/g, '');
|
||||
const phone = digits.length === 10 ? `52${digits}` : digits;
|
||||
try {
|
||||
setCreandoPaciente(true);
|
||||
const res = await odooApi.createPatient({
|
||||
name: nuevoNombre.trim() || selected.contact_name || phone,
|
||||
phone,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setPatientInfo(res.patient);
|
||||
setCrearPacienteOpen(false);
|
||||
toast.success('Paciente creado');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al crear paciente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCreandoPaciente(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cuando cambia la búsqueda, recargar lista pero no forzar selección
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
@@ -212,6 +278,21 @@ const WacrmMessages: FC = () => {
|
||||
};
|
||||
|
||||
const assignedMember = members.find((m) => m.id === selected?.assigned_agent_id);
|
||||
const agentName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
|
||||
|
||||
const conversationsFiltradas = conversations.filter((c) => {
|
||||
if (filtro === 'mias') return !!currentAgentId && c.assigned_agent_id === currentAgentId;
|
||||
if (filtro === 'sin_asignar') return !c.assigned_agent_id;
|
||||
if (filtro === 'no_leidas') return c.unread_count > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
const FILTROS = [
|
||||
{ key: 'todas', label: 'Todas' },
|
||||
{ key: 'mias', label: 'Mías' },
|
||||
{ key: 'sin_asignar', label: 'Sin asignar' },
|
||||
{ key: 'no_leidas', label: 'No leídas' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Layout title="WACRM — Mensajes" subtitle="Conversaciones de WhatsApp sincronizadas">
|
||||
@@ -260,9 +341,26 @@ const WacrmMessages: FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 h-[calc(100vh-220px)] min-h-[400px]">
|
||||
{/* Lista de conversaciones */}
|
||||
<Card className="lg:col-span-1 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-wrap gap-1.5 p-2.5 border-b border-theme-border shrink-0">
|
||||
{FILTROS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFiltro(f.key)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-full transition ${
|
||||
filtro === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Card.Body className="p-0 overflow-y-auto flex-1">
|
||||
<div className="divide-y">
|
||||
{conversations.map((c) => (
|
||||
{conversationsFiltradas.length === 0 && (
|
||||
<p className="text-sm text-theme-muted text-center py-8">Sin conversaciones con este filtro.</p>
|
||||
)}
|
||||
{conversationsFiltradas.map((c) => (
|
||||
<button
|
||||
key={c.external_id}
|
||||
onClick={() => handleSelect(c)}
|
||||
@@ -286,8 +384,8 @@ const WacrmMessages: FC = () => {
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p>
|
||||
{c.assigned_agent_id && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[120px]">
|
||||
Asignado
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[140px]">
|
||||
{agentName(c.assigned_agent_id) || 'Asignado'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -322,6 +420,47 @@ const WacrmMessages: FC = () => {
|
||||
{selected.assigned_agent_id ? 'Reasignar' : 'Asignar'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Ficha del paciente (match por teléfono) */}
|
||||
{patientSearched && patientInfo && (
|
||||
<div className="px-4 py-2.5 border-b border-theme-border bg-theme-surface flex items-center gap-3 flex-wrap">
|
||||
<Link
|
||||
to={`/pacientes?q=${encodeURIComponent(patientInfo.phone)}`}
|
||||
className="text-sm font-medium text-theme-heading hover:underline"
|
||||
>
|
||||
{patientInfo.name}
|
||||
</Link>
|
||||
<span className="text-xs text-theme-muted inline-flex items-center gap-1">
|
||||
<Calendar size={11} /> Última visita: {patientInfo.last_visit || '—'}
|
||||
</span>
|
||||
<span className="text-xs text-theme-muted inline-flex items-center gap-1">
|
||||
<Heart size={11} /> {patientInfo.wallet_points} pts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{patientSearched && !patientInfo && (
|
||||
<div className="px-4 py-2.5 border-b border-theme-border bg-theme-surface">
|
||||
{!crearPacienteOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNuevoNombre(selected.contact_name || ''); setCrearPacienteOpen(true); }}
|
||||
className="text-xs text-theme-heading underline inline-flex items-center gap-1"
|
||||
>
|
||||
<UserPlus size={12} /> No es paciente — Crear paciente
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Nombre del paciente"
|
||||
value={nuevoNombre}
|
||||
onChange={(e) => setNuevoNombre(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" onClick={crearPaciente} loading={creandoPaciente}>Crear</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCrearPacienteOpen(false)}>Cancelar</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-center text-sm text-theme-muted py-8">No hay mensajes en esta conversación</p>
|
||||
|
||||
Reference in New Issue
Block a user