Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,642 @@
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 Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
Badge,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
const stateOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'pending', label: 'Pendiente' },
{ value: 'confirmed', label: 'Confirmada' },
{ value: 'done', label: 'Completada' },
{ value: 'cancelled', label: 'Cancelada' },
];
const Agenda: FC = () => {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
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 [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [form, setForm] = useState({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
notes: '',
sessions: 1,
});
const [patientSearch, setPatientSearch] = useState('');
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
const [expressOpen, setExpressOpen] = useState(false);
const [expressSubmitting, setExpressSubmitting] = useState(false);
const [expressForm, setExpressForm] = useState({
name: '',
phone: '',
notes: '',
withAppointment: false,
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
});
const filteredAppointments = appointments.filter((apt) => {
if (stateFilter && apt.state !== stateFilter) return false;
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
return true;
});
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);
} catch (err) {
setError('Error al cargar citas');
toast.error('Error al cargar citas');
console.error(err);
} finally {
setLoading(false);
}
}, [date]);
const loadReferences = useCallback(async () => {
try {
const [servicesRes, doctorsRes] = await Promise.all([
odooApi.getServices(),
odooApi.getDoctors(),
]);
if (servicesRes.status === 'success') setServices(servicesRes.services);
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
} catch (err) {
console.error(err);
}
}, []);
useEffect(() => {
load();
loadReferences();
}, [load, loadReferences]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
const t = setTimeout(async () => {
try {
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
if (res.status === 'success') setPatients(res.patients);
} catch (err) {
console.error(err);
}
}, 250);
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
if (!serviceId || !aptDate) {
setSlots([]);
return;
}
try {
setSlotsLoading(true);
const res = await odooApi.getAvailableSlots(aptDate, parseInt(serviceId, 10));
if (res.status === 'success') setSlots(res.slots);
} catch {
toast.error('Error al cargar horarios disponibles');
setSlots([]);
} finally {
setSlotsLoading(false);
}
}, []);
useEffect(() => {
fetchSlots(form.service_id, form.date);
}, [form.service_id, form.date, fetchSlots]);
const handleStatus = async (id: number, action: string) => {
try {
setUpdating(id);
await odooApi.updateAppointmentStatus(id, action);
toast.success('Estado actualizado');
await load();
} catch (err) {
toast.error('Error al actualizar estado');
console.error(err);
} finally {
setUpdating(null);
}
};
const handlePackageFinished = async (id: number, finished: boolean) => {
try {
setPkgUpdating(id);
await odooApi.packageFinished(id, finished);
toast.success(finished ? 'Paquete marcado como terminado' : 'Marca de paquete revertida');
await load();
} catch (err) {
toast.error('Error al actualizar paquete');
console.error(err);
} finally {
setPkgUpdating(null);
}
};
const parseTimeToFloat = (t: string): number | undefined => {
if (!t) return undefined;
const [h, m] = t.split(':').map((x) => parseInt(x, 10));
if (Number.isNaN(h)) return undefined;
return h + (Number.isNaN(m) ? 0 : m / 60);
};
const resetExpressForm = () =>
setExpressForm({
name: '', phone: '', notes: '', withAppointment: false,
service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '',
});
const submitExpress = async () => {
if (!expressForm.name.trim() || !expressForm.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
if (expressForm.withAppointment && (!expressForm.service_id || !expressForm.date || !expressForm.time)) {
toast.error('Para la cita de valoración selecciona servicio, fecha y hora');
return;
}
try {
setExpressSubmitting(true);
const patientPayload: Partial<Patient> & { notes?: string } = {
name: expressForm.name.trim(),
phone: expressForm.phone.trim(),
source: 'walkin',
notes: expressForm.notes || undefined,
};
const res = await odooApi.createPatient(patientPayload);
const newId = res.patient?.id;
if (expressForm.withAppointment && newId) {
await odooApi.createAppointment({
patient_id: newId,
service_id: parseInt(expressForm.service_id, 10),
doctor_id: expressForm.doctor_id ? parseInt(expressForm.doctor_id, 10) : undefined,
date: expressForm.date,
time: parseTimeToFloat(expressForm.time) as unknown as string,
notes: 'Valoración express',
state: 'confirmed',
});
}
toast.success(expressForm.withAppointment ? 'Prospecto y cita de valoración creados' : 'Prospecto creado');
setExpressOpen(false);
resetExpressForm();
await load();
} catch (err) {
toast.error('Error en valoración express');
console.error(err);
} finally {
setExpressSubmitting(false);
}
};
const createAppointment = async () => {
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
toast.error('Completa los campos obligatorios');
return;
}
const service = services.find((s) => String(s.id) === form.service_id);
const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1);
try {
setSubmitting(true);
await odooApi.createAppointment({
patient_id: parseInt(form.patient_id, 10),
service_id: parseInt(form.service_id, 10),
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
date: form.date,
time: form.time,
notes: form.notes,
sessions,
});
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
setCreateOpen(false);
setForm({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
notes: '',
sessions: 1,
});
await load();
} catch (err) {
toast.error('Error al crear cita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const doctorOptions = [
{ value: '', label: 'Todos los médicos' },
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
];
const patientOptions = [
{ value: '', label: 'Seleccionar paciente...' },
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
];
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
...services.map((s) => ({
value: String(s.id),
label: (s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0
? `${s.name} — $${s.price} / ${s.package_sessions} sesiones $${s.package_price}`
: `${s.name} — $${s.price}`,
})),
];
const slotOptions = slots.length
? [
{ value: '', label: 'Seleccionar hora...' },
...slots.map((s) => ({ value: s.time_str, label: s.time_str })),
]
: [
{ value: '', label: 'Selecciona servicio y fecha primero' },
];
const TableHeader = () => (
<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 sm:table-cell">Servicio</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Doctor</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 hidden sm:table-cell">Pago</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
);
const ActionButtons = ({ apt }: { apt: Appointment }) => (
<div className="flex items-center gap-1">
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'confirm')}
disabled={updating === apt.id}
title="Confirmar"
>
<CheckCircle size={16} className="text-theme-heading" />
</Button>
)}
{apt.state === 'confirmed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'arrive')}
disabled={updating === apt.id}
title="Llegada"
>
<UserCheck size={16} className="text-blue-600" />
</Button>
)}
{apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'done')}
disabled={updating === apt.id}
title="Completar"
>
<CheckCheck size={16} className="text-blue-600" />
</Button>
)}
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
apt.package_finished ? (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, false)}
disabled={pkgUpdating === apt.id}
title="Paquete terminado — click para revertir"
>
<RotateCcw size={16} className="text-amber-600" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, true)}
disabled={pkgUpdating === apt.id}
title="Marcar paquete terminado"
>
<PackageCheck size={16} className="text-violet-600" />
</Button>
)
)}
{apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'cancel')}
disabled={updating === apt.id}
title="Cancelar"
>
<XCircle size={16} className="text-rose-600" />
</Button>
)}
</div>
);
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Nueva cita
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="sm:max-w-[180px]"
/>
<Select
label="Estado"
options={stateOptions}
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
className="sm:max-w-[180px]"
/>
<Select
label="Médico"
options={doctorOptions}
value={doctorFilter}
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
</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 ? (
<EmptyState
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
actionLabel="Nueva cita"
onAction={() => setCreateOpen(true)}
/>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{filteredAppointments.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 md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
<td className="p-3">
<ActionButtons apt={apt} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filteredAppointments.map((apt) => (
<MobileCard
key={apt.id}
title={apt.patient}
subtitle={apt.service}
rows={[
{ label: 'Hora', value: apt.time },
{ label: 'Doctor', value: apt.doctor || '-' },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> },
{ label: 'Pago', value: apt.payment_state },
]}
actions={<ActionButtons apt={apt} />}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nueva cita"
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Buscar paciente"
placeholder="Escribe nombre o teléfono..."
value={patientSearch}
onChange={(e) => setPatientSearch(e.target.value)}
/>
<Select
label="Paciente *"
options={patientOptions}
value={form.patient_id}
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
/>
<Select
label="Servicio *"
options={serviceOptions}
value={form.service_id}
onChange={(e) => setForm({ ...form, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.doctor_id}
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
/>
<Select
label="Hora *"
options={slotOptions}
value={form.time}
onChange={(e) => setForm({ ...form, time: e.target.value })}
disabled={slotsLoading || !form.service_id || !form.date}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Sesiones"
options={[
{ value: '1', label: '1 sesión' },
{ value: '2', label: '2 sesiones' },
{ value: '3', label: '3 sesiones' },
{ value: '4', label: '4 sesiones' },
{ value: '5', label: '5 sesiones' },
{ value: '6', label: '6 sesiones' },
]}
value={String(form.sessions)}
onChange={(e) => setForm({ ...form, sessions: parseInt(e.target.value, 10) })}
/>
<div className="flex items-end">
<p className="text-xs text-theme-muted pb-2">
Si el servicio tiene paquete, se sugerirá el número de sesiones del paquete.
</p>
</div>
</div>
{slotsLoading && <p className="text-xs text-theme-muted">Cargando horarios disponibles...</p>}
<TextArea
label="Notas"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</Modal>
<Modal
isOpen={expressOpen}
onClose={() => setExpressOpen(false)}
title="Valoración express"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setExpressOpen(false)}>Cancelar</Button>
<Button onClick={submitExpress} loading={expressSubmitting}>Guardar</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Captura rápida de prospecto. Opcionalmente agenda su cita de valoración en el mismo paso.
</p>
<Input
label="Nombre *"
placeholder="Nombre del prospecto"
value={expressForm.name}
onChange={(e) => setExpressForm({ ...expressForm, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={expressForm.phone}
onChange={(e) => setExpressForm({ ...expressForm, phone: e.target.value })}
/>
<TextArea
label="Notas"
placeholder="Motivo de consulta, zona a tratar, etc."
value={expressForm.notes}
onChange={(e) => setExpressForm({ ...expressForm, notes: e.target.value })}
/>
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={expressForm.withAppointment}
onChange={(e) => setExpressForm({ ...expressForm, withAppointment: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Crear cita de valoración ahora
</label>
{expressForm.withAppointment && (
<div className="space-y-4 rounded-xl border border-theme-border p-4 bg-theme-bg">
<Select
label="Servicio *"
options={serviceOptions}
value={expressForm.service_id}
onChange={(e) => setExpressForm({ ...expressForm, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={expressForm.doctor_id}
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={expressForm.date}
onChange={(e) => setExpressForm({ ...expressForm, date: e.target.value })}
/>
<Input
label="Hora *"
type="time"
value={expressForm.time}
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
/>
</div>
</div>
)}
</div>
</Modal>
</Layout>
);
};
export default Agenda;