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:
642
frontend/src/pages/Agenda.tsx
Normal file
642
frontend/src/pages/Agenda.tsx
Normal 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;
|
||||
241
frontend/src/pages/Configuracion.tsx
Normal file
241
frontend/src/pages/Configuracion.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
const defaultSettings: ClinicSettings = {
|
||||
name: 'SKEEN Derma Experts',
|
||||
phone: '+52 (661) 100-2172',
|
||||
email: 'hola@skeen.mx',
|
||||
address: 'Playas de Rosarito, Baja California',
|
||||
website: 'https://skeen.mx',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) setSettings(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
toast.success('Configuración guardada');
|
||||
} catch {
|
||||
toast.error('Error al guardar configuración');
|
||||
}
|
||||
};
|
||||
|
||||
const update = (field: keyof ClinicSettings, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// ---- Tipo de cambio USD/MXN ----
|
||||
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
|
||||
const [tcInput, setTcInput] = useState('');
|
||||
const [tcSaving, setTcSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getExchangeRate()
|
||||
.then((res) => {
|
||||
if (res.current) {
|
||||
setTcCurrent(res.current);
|
||||
setTcInput(String(res.current.rate));
|
||||
}
|
||||
})
|
||||
.catch(() => {/* odoo no disponible, ignorar */});
|
||||
}, []);
|
||||
|
||||
const saveTc = async () => {
|
||||
const rate = parseFloat(tcInput);
|
||||
if (!rate || rate <= 0) {
|
||||
toast.error('Ingresa un tipo de cambio válido');
|
||||
return;
|
||||
}
|
||||
setTcSaving(true);
|
||||
try {
|
||||
const res = await odooApi.setExchangeRate(rate, 'manual');
|
||||
setTcCurrent(res.exchange_rate);
|
||||
toast.success(`Tipo de cambio actualizado: ${rate.toFixed(2)} MXN/USD`);
|
||||
} catch {
|
||||
toast.error('No se pudo guardar el tipo de cambio');
|
||||
} finally {
|
||||
setTcSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Configuración" subtitle="Ajustes de la clínica">
|
||||
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
<MapPin size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Dirección</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`tel:${settings.phone}`}
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
<Phone size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`mailto:${settings.email}`}
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
<Mail size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Email</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.email}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={settings.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
|
||||
<Building2 size={20} className="mr-2" />
|
||||
Ajustes
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-theme-muted">Cargando...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre de la clínica"
|
||||
value={settings.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Teléfono"
|
||||
value={settings.phone}
|
||||
onChange={(e) => update('phone', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Dirección"
|
||||
value={settings.address}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.website}
|
||||
onChange={(e) => update('website', e.target.value)}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<Button onClick={save}>
|
||||
<Save size={16} className="mr-2" />
|
||||
Guardar ajustes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||
<ArrowRightLeft size={20} className="mr-2" />
|
||||
Tipo de cambio USD / MXN
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mb-4">
|
||||
Se usa para convertir ventas en dólares. Vigente:
|
||||
{tcCurrent ? (
|
||||
<span className="font-semibold text-theme-heading"> {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD <span className="font-normal text-theme-muted">({tcCurrent.date})</span></span>
|
||||
) : (
|
||||
<span className="text-theme-muted"> sin definir</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md">
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Nuevo tipo de cambio (MXN por 1 USD)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={tcInput}
|
||||
onChange={(e) => setTcInput(e.target.value)}
|
||||
placeholder="17.25"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={saveTc} disabled={tcSaving}>
|
||||
<Save size={16} className="mr-2" />
|
||||
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Configuracion;
|
||||
262
frontend/src/pages/Cortes.tsx
Normal file
262
frontend/src/pages/Cortes.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Lock, Receipt } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
badgeForCashClosingState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type CashClosing } from '../services/odoo';
|
||||
|
||||
const Cortes: FC = () => {
|
||||
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [closeOpen, setCloseOpen] = useState<CashClosing | null>(null);
|
||||
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
const [openingCash, setOpeningCash] = useState('');
|
||||
const [closingCash, setClosingCash] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getCashClosings();
|
||||
if (res.status === 'success') setClosings(res.cash_closings);
|
||||
} catch (err) {
|
||||
setError('Error al cargar cortes');
|
||||
toast.error('Error al cargar cortes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
const value = parseFloat(openingCash);
|
||||
if (Number.isNaN(value) || value < 0) {
|
||||
toast.error('Ingresa un efectivo inicial válido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createCashClosing({ date, opening_cash: value });
|
||||
toast.success('Caja abierta');
|
||||
setCreateOpen(false);
|
||||
setOpeningCash('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear corte');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
if (!closeOpen) return;
|
||||
const value = parseFloat(closingCash);
|
||||
if (Number.isNaN(value) || value < 0) {
|
||||
toast.error('Ingresa un efectivo final válido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.closeCashClosing(closeOpen.id, value);
|
||||
toast.success('Caja cerrada');
|
||||
setCloseOpen(null);
|
||||
setClosingCash('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al cerrar caja');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderUser = (user: string | false) => {
|
||||
if (!user) return 'Usuario no disponible';
|
||||
return user;
|
||||
};
|
||||
|
||||
const differenceColor = (diff: number) => {
|
||||
if (diff > 0) return 'text-theme-heading';
|
||||
if (diff < 0) return 'text-rose-600';
|
||||
return 'text-theme-muted';
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
|
||||
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Abrir caja
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-14 w-full" />
|
||||
) : closings.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin cortes"
|
||||
subtitle="No hay cortes de caja registrados."
|
||||
actionLabel="Abrir caja"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
icon={<Receipt 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">Fecha</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Usuario</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Apertura</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Ventas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Diferencia</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">
|
||||
{closings.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{c.name}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{c.date}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{renderUser(c.user)}</td>
|
||||
<td className="p-3 text-sm text-theme-heading">${c.opening_cash}</td>
|
||||
<td className="p-3 text-sm text-theme-heading hidden lg:table-cell">${c.total_sales}</td>
|
||||
<td className={`p-3 text-sm font-semibold ${differenceColor(c.difference)}`}>
|
||||
{c.difference > 0 ? '+' : ''}${c.difference}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{c.state !== 'closed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)} title="Cerrar caja">
|
||||
<Lock size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{closings.map((c) => (
|
||||
<MobileCard
|
||||
key={c.id}
|
||||
title={c.name}
|
||||
subtitle={<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>}
|
||||
rows={[
|
||||
{ label: 'Fecha', value: c.date },
|
||||
{ label: 'Usuario', value: renderUser(c.user) },
|
||||
{ label: 'Apertura', value: `$${c.opening_cash}` },
|
||||
{ label: 'Diferencia', value: `${c.difference > 0 ? '+' : ''}$${c.difference}` },
|
||||
]}
|
||||
actions={
|
||||
c.state !== 'closed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)}>
|
||||
<Lock size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Abrir caja"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Abrir caja</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Fecha"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Efectivo inicial"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={openingCash}
|
||||
onChange={(e) => setOpeningCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!closeOpen}
|
||||
onClose={() => setCloseOpen(null)}
|
||||
title="Cerrar caja"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCloseOpen(null)}>Cancelar</Button>
|
||||
<Button onClick={close} loading={submitting}>Cerrar caja</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{closeOpen && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Corte: <span className="font-medium text-theme-heading">{closeOpen.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Ventas registradas: <span className="font-medium text-theme-heading">${closeOpen.total_sales}</span>
|
||||
</p>
|
||||
<Input
|
||||
label="Efectivo final"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={closingCash}
|
||||
onChange={(e) => setClosingCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cortes;
|
||||
167
frontend/src/pages/Cumpleanos.tsx
Normal file
167
frontend/src/pages/Cumpleanos.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Cake, MessageCircle, Star, Download } from 'lucide-react';
|
||||
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';
|
||||
|
||||
type Period = 'today' | 'week' | 'month';
|
||||
|
||||
const PERIODS: { value: Period; label: string }[] = [
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Próximos 7 días' },
|
||||
{ value: 'month', label: 'Este mes' },
|
||||
];
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const fmtDate = (s: string | null) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(`${s}T12:00:00`);
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||
};
|
||||
|
||||
const Cumpleanos: FC = () => {
|
||||
const [period, setPeriod] = useState<Period>('month');
|
||||
const [items, setItems] = useState<Birthday[]>([]);
|
||||
const [range, setRange] = useState<{ start: string; end: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
odooApi
|
||||
.getBirthdays(period)
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setItems(res.birthdays || []);
|
||||
setRange({ start: res.start, end: res.end });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [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)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Cumpleañeros" subtitle="Pacientes que cumplen años">
|
||||
<PageHeader title="Cumpleañeros" subtitle="Pacientes que cumplen años en el periodo">
|
||||
<Button variant="outline" onClick={exportBirthdays} disabled={items.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card className="mb-4">
|
||||
<Card.Body>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition ${
|
||||
period === p.value
|
||||
? 'bg-[#1A1A1A] text-white'
|
||||
: 'bg-theme-bg text-theme-muted hover:bg-theme-border'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{range && (
|
||||
<span className="ml-auto text-xs text-theme-muted">
|
||||
{range.start} → {range.end} · {items.length} pacientes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body noPadding>
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-theme-muted">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center text-theme-muted">
|
||||
<Cake className="mx-auto mb-2" size={28} />
|
||||
<p className="text-sm">No hay cumpleañeros en este periodo.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Fecha</th>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3">Cumple</th>
|
||||
<th className="text-left px-4 py-3">Última visita</th>
|
||||
<th className="text-right px-4 py-3">Total gastado</th>
|
||||
<th className="text-right px-4 py-3">Adeudo</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{items.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{fmtDate(b.occurs_on)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{b.is_vip && <Star size={13} className="text-amber-500 fill-amber-500" />}
|
||||
<span className="text-theme-heading">{b.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-theme-muted">{b.phone}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted">{b.turning_age} años</td>
|
||||
<td className="px-4 py-3 text-theme-muted">{fmtDate(b.last_visit)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(b.total_spent)}</td>
|
||||
<td className={`px-4 py-3 text-right ${b.amount_due > 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
|
||||
{fmtMoney(b.amount_due)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{b.phone && (
|
||||
<a
|
||||
href={`https://wa.me/${b.phone.replace(/\D/g, '')}?text=${encodeURIComponent(
|
||||
`¡Feliz cumpleaños ${b.name.split(' ')[0]}! 🎉 Te esperamos en SKEEN Derma Experts.`
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-theme-heading hover:text-theme-heading text-xs font-medium"
|
||||
>
|
||||
<MessageCircle size={14} /> WhatsApp
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cumpleanos;
|
||||
572
frontend/src/pages/Dashboard.tsx
Normal file
572
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
DollarSign,
|
||||
ShoppingCart,
|
||||
Clock,
|
||||
Wallet,
|
||||
Sparkles,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
SkeletonStat,
|
||||
Skeleton,
|
||||
toast,
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: ReactNode;
|
||||
trend?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) => {
|
||||
if (loading) return <SkeletonStat />;
|
||||
return (
|
||||
<Card className="hover:shadow-md transition">
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-theme-success/50 flex items-center justify-center text-theme-heading">
|
||||
{icon}
|
||||
</div>
|
||||
{trend && (
|
||||
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted mb-1">{title}</p>
|
||||
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard: FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [chartData, setChartData] = useState<ChartPoint[]>([]);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
const [loadingChart, setLoadingChart] = useState(true);
|
||||
const [loadingAppointments, setLoadingAppointments] = useState(true);
|
||||
const [loadingServices, setLoadingServices] = useState(true);
|
||||
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
|
||||
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
|
||||
const [loadingExtras, setLoadingExtras] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const res = await odooApi.getDashboard();
|
||||
if (res.status === 'success' && res.stats) {
|
||||
setStats(res.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar estadísticas del dashboard');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChart = async () => {
|
||||
try {
|
||||
setLoadingChart(true);
|
||||
const res = await odooApi.getWeeklyChart();
|
||||
if (res.status === 'success' && res.data) {
|
||||
setChartData(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar gráfica semanal');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingChart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAppointments = async () => {
|
||||
try {
|
||||
setLoadingAppointments(true);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const res = await odooApi.getAppointments({ date: today, limit: '5' });
|
||||
if (res.status === 'success' && res.appointments) {
|
||||
setAppointments(res.appointments.slice(0, 5));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar próximas citas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingAppointments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadServices = async () => {
|
||||
try {
|
||||
setLoadingServices(true);
|
||||
const res = await odooApi.getServices();
|
||||
if (res.status === 'success' && res.services) {
|
||||
setServices(res.services.slice(0, 6));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar servicios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingServices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExtras = async () => {
|
||||
try {
|
||||
setLoadingExtras(true);
|
||||
const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
|
||||
const end = new Date().toISOString().split('T')[0];
|
||||
const [topRes, aptRes] = await Promise.all([
|
||||
odooApi.getTopServices(start, end, 6),
|
||||
odooApi.getAppointmentsReport(start, end),
|
||||
]);
|
||||
if (topRes.status === 'success') setTopServices(topRes.services || []);
|
||||
if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingExtras(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
loadChart();
|
||||
loadAppointments();
|
||||
loadServices();
|
||||
loadExtras();
|
||||
}, []);
|
||||
|
||||
const statsConfig = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'Citas Hoy',
|
||||
value: stats?.appointments_today ?? 0,
|
||||
icon: <Calendar size={20} />,
|
||||
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
||||
},
|
||||
{
|
||||
title: 'Pacientes',
|
||||
value: stats?.total_patients ?? 0,
|
||||
icon: <Users size={20} />,
|
||||
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
|
||||
},
|
||||
{
|
||||
title: 'Ingresos Mes',
|
||||
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
||||
icon: <DollarSign size={20} />,
|
||||
},
|
||||
{
|
||||
title: 'Ventas Hoy',
|
||||
value: stats?.sales_today ?? 0,
|
||||
icon: <ShoppingCart size={20} />,
|
||||
},
|
||||
{
|
||||
title: 'Pendientes',
|
||||
value: stats?.pending_payments ?? 0,
|
||||
icon: <Clock size={20} />,
|
||||
},
|
||||
{
|
||||
title: 'Puntos Monedero',
|
||||
value: stats?.total_wallet_points ?? 0,
|
||||
icon: <Wallet size={20} />,
|
||||
},
|
||||
],
|
||||
[stats]
|
||||
);
|
||||
|
||||
const activityItems = useMemo(() => {
|
||||
return appointments.slice(0, 4).map((apt) => ({
|
||||
id: apt.id,
|
||||
title: `${apt.patient} — ${apt.service}`,
|
||||
subtitle: `${apt.date} ${apt.time}`,
|
||||
state: apt.state,
|
||||
}));
|
||||
}, [appointments]);
|
||||
|
||||
const attendanceData = useMemo(() => {
|
||||
if (!aptReport) return [] as { name: string; value: number; color: string }[];
|
||||
const s = aptReport.by_state || {};
|
||||
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
|
||||
return [
|
||||
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
|
||||
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
|
||||
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
|
||||
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
|
||||
{ name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' },
|
||||
].filter((d) => d.value > 0);
|
||||
}, [aptReport]);
|
||||
|
||||
const noShowRate = useMemo(() => {
|
||||
if (!aptReport || !aptReport.total) return 0;
|
||||
return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100);
|
||||
}, [aptReport]);
|
||||
|
||||
return (
|
||||
<Layout title="Dashboard" subtitle="Panel de control de SKEEN Derma Experts">
|
||||
{isHomeNest ? (
|
||||
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||
<div className="max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium mb-4" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
|
||||
<Sparkles size={14} /> Bienvenido de vuelta
|
||||
</div>
|
||||
<h1 className="font-display text-3xl sm:text-4xl lg:text-5xl mb-3" style={{ color: 'var(--text-heading)' }}>SKEEN Derma Experts</h1>
|
||||
<p className="text-base sm:text-lg leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
<Button variant="primary" size="md">
|
||||
<Calendar size={16} className="mr-2" /> Nueva cita
|
||||
</Button>
|
||||
<Button variant="outline" size="md">
|
||||
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-8 pt-8" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
{statsConfig.slice(0, 4).map((s) => (
|
||||
<div key={s.title} className="text-center sm:text-left">
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-muted)' }}>{s.title}</p>
|
||||
<p className="text-xl sm:text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{loadingStats ? '-' : s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
|
||||
<Button variant="outline" size="sm">
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Bienvenido
|
||||
</Button>
|
||||
</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">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl sm:text-3xl mb-2">SKEEN Derma Experts</h1>
|
||||
<p className="text-white/90 text-sm sm:text-base max-w-xl">
|
||||
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
|
||||
</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">
|
||||
<TrendingUp size={16} className="mr-2" />
|
||||
Ver reportes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
|
||||
{statsConfig.map((s) => (
|
||||
<StatCard
|
||||
key={s.title}
|
||||
title={s.title}
|
||||
value={s.value}
|
||||
icon={s.icon}
|
||||
trend={s.trend}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart + appointments */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
|
||||
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{loadingChart ? (
|
||||
<Skeleton count={8} className="h-6 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
|
||||
) : (
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorAppointments" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#EDA588" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#EDA588" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#CAE2C1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#CAE2C1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E5E0DC" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="appointments" stroke="#EDA588" fillOpacity={1} fill="url(#colorAppointments)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="revenue" stroke="#CAE2C1" fillOpacity={1} fill="url(#colorRevenue)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col">
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
|
||||
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
|
||||
Ver agenda
|
||||
<ArrowRight size={14} className="ml-1" />
|
||||
</Button>
|
||||
</Card.Header>
|
||||
<Card.Body className="flex-1">
|
||||
{loadingAppointments ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{appointments.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="flex items-center justify-between p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
||||
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
||||
</div>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top servicios + Asistencia del mes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
|
||||
<p className="text-xs text-theme-muted">Por ingresos generados</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{loadingExtras ? (
|
||||
<Skeleton count={6} className="h-6 w-full" />
|
||||
) : topServices.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
|
||||
) : (
|
||||
<div className="h-[280px] w-full">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={topServices} layout="vertical" margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E0DC" />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: '#6B6B6B' }} axisLine={false} tickLine={false} tickFormatter={(v: number) => formatCurrency(v)} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="service"
|
||||
width={150}
|
||||
tick={{ fontSize: 11, fill: '#1A1A1A' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: string) => (v.length > 22 ? `${v.slice(0, 22)}…` : v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => formatCurrency(Number(v))}
|
||||
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<Bar dataKey="revenue" fill="#EDA588" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Asistencia del mes</h3>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
|
||||
</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{loadingExtras ? (
|
||||
<Skeleton count={6} className="h-6 w-full" />
|
||||
) : attendanceData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="h-[240px] w-full sm:w-1/2">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={attendanceData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={95} paddingAngle={2}>
|
||||
{attendanceData.map((d, i) => (
|
||||
<Cell key={`cell-${i}`} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 w-full space-y-2">
|
||||
{attendanceData.map((d) => (
|
||||
<div key={d.name} className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-theme-muted">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="font-medium text-theme-heading">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Services + activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
|
||||
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{loadingServices ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{services.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: s.color || '#78716c' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{s.name}</p>
|
||||
<p className="text-xs text-theme-muted">${s.price} • {s.duration_min} min</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
|
||||
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{loadingAppointments ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : activityItems.length === 0 ? (
|
||||
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activityItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-theme-border flex items-center justify-center text-theme-muted shrink-0 mt-0.5">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{item.title}</p>
|
||||
<p className="text-xs text-theme-muted">{item.subtitle}</p>
|
||||
</div>
|
||||
<Badge variant={badgeForAppointmentState(item.state)} className="shrink-0">
|
||||
{item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
315
frontend/src/pages/Inventario.tsx
Normal file
315
frontend/src/pages/Inventario.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
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 fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const LEVEL_META: Record<InventoryLevel, { label: string; cls: string }> = {
|
||||
out: { label: 'Sin existencias', cls: 'bg-rose-100 text-rose-700' },
|
||||
critical: { label: 'Crítico', cls: 'bg-orange-100 text-orange-700' },
|
||||
low: { label: 'Bajo', cls: 'bg-theme-bg text-theme-warning-text' },
|
||||
optimal: { label: 'Óptimo', cls: 'bg-theme-success text-theme-heading' },
|
||||
};
|
||||
|
||||
const MOVE_META: Record<InventoryMoveType, { label: string; sign: string; cls: string }> = {
|
||||
compra: { label: 'Compra', sign: '+', cls: 'text-theme-heading' },
|
||||
venta: { label: 'Venta', sign: '−', cls: 'text-theme-muted' },
|
||||
baja: { label: 'Baja', sign: '−', cls: 'text-rose-600' },
|
||||
ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' },
|
||||
};
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: '', label: 'Todos' },
|
||||
{ value: 'producto', label: 'Productos' },
|
||||
{ value: 'consumible', label: 'Consumibles' },
|
||||
];
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: '', label: 'Todos los niveles' },
|
||||
{ value: 'optimal', label: 'Óptimo' },
|
||||
{ value: 'low', label: 'Bajo' },
|
||||
{ value: 'critical', label: 'Crítico' },
|
||||
{ value: 'out', label: 'Sin existencias' },
|
||||
];
|
||||
|
||||
const MOVE_OPTIONS = [
|
||||
{ value: 'compra', label: 'Compra (entrada)' },
|
||||
{ value: 'venta', label: 'Venta (salida)' },
|
||||
{ value: 'baja', label: 'Baja (merma)' },
|
||||
{ value: 'ajuste', label: 'Ajuste (fijar conteo físico)' },
|
||||
];
|
||||
|
||||
const emptyItem: Partial<InventoryItem> = {
|
||||
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
|
||||
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, expiry_date: null, notes: '',
|
||||
};
|
||||
|
||||
const Inventario: FC = () => {
|
||||
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);
|
||||
const [kind, setKind] = useState<string>('');
|
||||
const [level, setLevel] = useState<string>('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newForm, setNewForm] = useState<Partial<InventoryItem>>(emptyItem);
|
||||
const [savingNew, setSavingNew] = useState(false);
|
||||
|
||||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||
const [moveType, setMoveType] = useState<InventoryMoveType>('compra');
|
||||
const [moveQty, setMoveQty] = useState('');
|
||||
const [moveRef, setMoveRef] = useState('');
|
||||
const [savingMove, setSavingMove] = useState(false);
|
||||
|
||||
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
|
||||
const [moves, setMoves] = useState<InventoryMove[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getInventory({
|
||||
...(kind ? { kind: kind as InventoryKind } : {}),
|
||||
...(level ? { level: level as InventoryLevel } : {}),
|
||||
...(search ? { search } : {}),
|
||||
});
|
||||
setItems(res.items || []);
|
||||
setSummary(res.summary);
|
||||
} catch {
|
||||
toast.error('No se pudo cargar el inventario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind, level, search]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const createItem = async () => {
|
||||
if (!newForm.name) { toast.error('El nombre es obligatorio'); return; }
|
||||
setSavingNew(true);
|
||||
try {
|
||||
await odooApi.createInventoryItem(newForm);
|
||||
toast.success('Item creado');
|
||||
setNewOpen(false);
|
||||
setNewForm(emptyItem);
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Error al crear el item');
|
||||
} finally {
|
||||
setSavingNew(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdjust = (it: InventoryItem) => {
|
||||
setAdjustItem(it);
|
||||
setMoveType('compra');
|
||||
setMoveQty('');
|
||||
setMoveRef('');
|
||||
};
|
||||
|
||||
const submitMove = async () => {
|
||||
if (!adjustItem) return;
|
||||
const q = parseFloat(moveQty);
|
||||
if (isNaN(q) || q < 0) { toast.error('Cantidad inválida'); return; }
|
||||
setSavingMove(true);
|
||||
try {
|
||||
await odooApi.createInventoryMove(adjustItem.id, { type: moveType, qty: q, reference: moveRef });
|
||||
toast.success('Movimiento registrado');
|
||||
setAdjustItem(null);
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Error al registrar el movimiento');
|
||||
} finally {
|
||||
setSavingMove(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openHistory = async (it: InventoryItem) => {
|
||||
setHistoryItem(it);
|
||||
try {
|
||||
const res = await odooApi.getInventoryMoves(it.id);
|
||||
setMoves(res.moves || []);
|
||||
} catch {
|
||||
setMoves([]);
|
||||
}
|
||||
};
|
||||
|
||||
const lvl = summary.levels;
|
||||
|
||||
return (
|
||||
<Layout title="Inventario" subtitle="Productos y consumibles">
|
||||
<PageHeader
|
||||
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>
|
||||
</PageHeader>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Valor total</p>
|
||||
<p className="text-xl font-semibold text-theme-heading">{fmtMoney(summary.total_value)}</p>
|
||||
<p className="text-xs text-theme-muted">{summary.count} items</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<div className="flex items-center gap-2"><PackageCheck size={16} className="text-theme-heading" /><p className="text-xs text-theme-muted">Óptimo</p></div>
|
||||
<p className="text-xl font-semibold text-theme-heading">{lvl.optimal}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<div className="flex items-center gap-2"><AlertTriangle size={16} className="text-amber-600" /><p className="text-xs text-theme-muted">Bajo / Crítico</p></div>
|
||||
<p className="text-xl font-semibold text-theme-warning-text">{lvl.low + lvl.critical}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<div className="flex items-center gap-2"><Boxes size={16} className="text-rose-600" /><p className="text-xs text-theme-muted">Sin existencias</p></div>
|
||||
<p className="text-xl font-semibold text-rose-700">{lvl.out}</p>
|
||||
</Card.Body></Card>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<Card className="mb-4"><Card.Body>
|
||||
<div className="flex flex-col lg:flex-row gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KIND_OPTIONS.map((k) => (
|
||||
<button key={k.value} onClick={() => setKind(k.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition ${kind === k.value ? 'bg-[#1A1A1A] text-white' : 'bg-theme-bg text-theme-muted hover:bg-theme-border'}`}>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input placeholder="Buscar por nombre, SKU o categoría" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="w-full lg:w-56">
|
||||
<Select options={LEVEL_OPTIONS} value={level} onChange={(e) => setLevel(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body></Card>
|
||||
|
||||
{/* Tabla */}
|
||||
<Card><Card.Body noPadding>
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-theme-muted">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center text-theme-muted">
|
||||
<Boxes className="mx-auto mb-2" size={28} />
|
||||
<p className="text-sm">No hay items. Crea el primero con “Nuevo item”.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Item</th>
|
||||
<th className="text-left px-4 py-3">Tipo</th>
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-left px-4 py-3">Nivel</th>
|
||||
<th className="text-right px-4 py-3">Costo</th>
|
||||
<th className="text-right px-4 py-3">Valor</th>
|
||||
<th className="text-left px-4 py-3">Caducidad</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{items.map((it) => (
|
||||
<tr key={it.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{it.name}</p>
|
||||
<p className="text-xs text-theme-muted">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
|
||||
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(it.inventory_value)}</td>
|
||||
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
|
||||
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body></Card>
|
||||
|
||||
{/* Modal nuevo item */}
|
||||
<Modal isOpen={newOpen} onClose={() => setNewOpen(false)} title="Nuevo item de inventario" maxWidth="lg"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createItem} loading={savingNew}>Guardar</Button>
|
||||
</>}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2"><Input label="Nombre" value={newForm.name || ''} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} /></div>
|
||||
<Select label="Tipo" options={[{ value: 'producto', label: 'Producto' }, { value: 'consumible', label: 'Consumible' }]} value={newForm.kind || 'producto'} onChange={(e) => setNewForm({ ...newForm, kind: e.target.value as InventoryKind })} />
|
||||
<Input label="Unidad" value={newForm.unit || ''} onChange={(e) => setNewForm({ ...newForm, unit: e.target.value })} placeholder="pieza / caja / vial" />
|
||||
<Input label="SKU / Código" value={newForm.sku || ''} onChange={(e) => setNewForm({ ...newForm, sku: e.target.value })} />
|
||||
<Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} />
|
||||
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
|
||||
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
|
||||
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
|
||||
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
|
||||
{newForm.kind === 'consumible' && (
|
||||
<Input label="Caducidad" type="date" value={newForm.expiry_date || ''} onChange={(e) => setNewForm({ ...newForm, expiry_date: e.target.value || null })} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal ajustar / movimiento */}
|
||||
<Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setAdjustItem(null)}>Cancelar</Button>
|
||||
<Button onClick={submitMove} loading={savingMove}>Registrar</Button>
|
||||
</>}>
|
||||
{adjustItem && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">Existencia actual: <span className="font-semibold text-theme-heading">{adjustItem.qty} {adjustItem.unit}</span></p>
|
||||
<Select label="Tipo de movimiento" options={MOVE_OPTIONS} value={moveType} onChange={(e) => setMoveType(e.target.value as InventoryMoveType)} />
|
||||
<Input label={moveType === 'ajuste' ? 'Nuevo conteo (existencia final)' : 'Cantidad'} type="number" min="0" step="0.01" value={moveQty} onChange={(e) => setMoveQty(e.target.value)} />
|
||||
<Input label="Referencia (opcional)" value={moveRef} onChange={(e) => setMoveRef(e.target.value)} placeholder="OC, folio, nota..." />
|
||||
{moveType === 'ajuste' && <p className="text-xs text-sky-700 bg-sky-50 rounded-lg p-2">El ajuste fija la existencia al valor capturado (conteo físico).</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal historial */}
|
||||
<Modal isOpen={!!historyItem} onClose={() => setHistoryItem(null)} title={historyItem ? `Movimientos: ${historyItem.name}` : 'Movimientos'} maxWidth="lg">
|
||||
{moves.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin movimientos registrados.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{moves.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between p-3 bg-theme-bg rounded-xl text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{m.type === 'compra' ? <ArrowDownToLine size={16} className="text-theme-heading" /> : <ArrowUpFromLine size={16} className="text-theme-muted" />}
|
||||
<div>
|
||||
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
|
||||
<p className="text-xs text-theme-muted">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted">{m.before_qty} → <span className="font-semibold text-theme-heading">{m.after_qty}</span></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inventario;
|
||||
119
frontend/src/pages/Login.tsx
Normal file
119
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { FC, FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { Button, Input } from '../components/ui';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
|
||||
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
|
||||
|
||||
const Login: FC = () => {
|
||||
const { login } = useAuth();
|
||||
const { theme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const logo = isHomeNest ? LOGO_HOMENEST : LOGO_SKEEN;
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!username.trim() || !password) {
|
||||
setError('Escribe usuario y contraseña');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await login(username.trim(), password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo iniciar sesión');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden transition-colors"
|
||||
style={{ backgroundColor: isHomeNest ? 'var(--bg)' : 'var(--color-dark)' }}
|
||||
>
|
||||
{/* Círculos decorativos de la paleta */}
|
||||
<div
|
||||
className="absolute top-[-10%] right-[-5%] w-72 h-72 rounded-full blur-3xl"
|
||||
style={{ backgroundColor: isHomeNest ? 'var(--color-coral)' : 'var(--color-coral)', opacity: isHomeNest ? 0.2 : 0.2 }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-[-10%] left-[-5%] w-80 h-80 rounded-full blur-3xl"
|
||||
style={{ backgroundColor: isHomeNest ? 'var(--color-mint)' : 'var(--color-mint)', opacity: isHomeNest ? 0.3 : 0.15 }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full blur-3xl"
|
||||
style={{ backgroundColor: isHomeNest ? 'var(--color-sky)' : 'var(--color-lilac)', opacity: isHomeNest ? 0.4 : 0.1 }}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<div className="text-center mb-10">
|
||||
<img
|
||||
src={logo}
|
||||
alt="SKEEN Derma Experts"
|
||||
className="h-10 w-auto object-contain mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="font-display text-3xl mb-2 tracking-wide" style={{ color: isHomeNest ? 'var(--text-heading)' : '#fff' }}>Bienvenido</h1>
|
||||
<p className="text-sm" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}>Inicia sesión para continuar</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="rounded-3xl p-7 space-y-5 border"
|
||||
style={{
|
||||
backgroundColor: isHomeNest ? 'var(--surface)' : 'rgba(255,255,255,0.05)',
|
||||
borderColor: isHomeNest ? 'var(--border)' : 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: isHomeNest ? 'none' : 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label="Usuario"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
inputClassName={isHomeNest
|
||||
? 'bg-theme-bg border-theme-border-strong text-theme-heading placeholder:text-theme-muted/60 focus:border-theme-accent focus:ring-theme-accent/30'
|
||||
: 'bg-theme-surface/10 border-white/10 text-white placeholder:text-white/40 focus:border-theme-accent focus:ring-theme-accent/30'
|
||||
}
|
||||
labelClassName={isHomeNest ? 'text-theme-muted' : 'text-white/70'}
|
||||
/>
|
||||
<Input
|
||||
label="Contraseña"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
inputClassName={isHomeNest
|
||||
? 'bg-theme-bg border-theme-border-strong text-theme-heading placeholder:text-theme-muted/60 focus:border-theme-accent focus:ring-theme-accent/30'
|
||||
: 'bg-theme-surface/10 border-white/10 text-white placeholder:text-white/40 focus:border-theme-accent focus:ring-theme-accent/30'
|
||||
}
|
||||
labelClassName={isHomeNest ? 'text-theme-muted' : 'text-white/70'}
|
||||
/>
|
||||
{error && <p className="text-sm" style={{ color: 'var(--color-coral)' }}>{error}</p>}
|
||||
<Button type="submit" className="w-full" loading={submitting}>
|
||||
<LogIn size={16} className="mr-2" />
|
||||
Entrar
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
|
||||
SKEEN Derma Experts · Playas de Rosarito, B.C.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
170
frontend/src/pages/Medicos.tsx
Normal file
170
frontend/src/pages/Medicos.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor } from '../services/odoo';
|
||||
|
||||
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
||||
|
||||
const isMedicalTitle = (jobTitle?: string) => {
|
||||
if (!jobTitle) return false;
|
||||
const normalized = jobTitle.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
return medicalTerms.some((term) => normalized.includes(term));
|
||||
};
|
||||
|
||||
const Medicos: FC = () => {
|
||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getDoctors();
|
||||
if (res.status === 'success') setEmployees(res.doctors);
|
||||
} catch (err) {
|
||||
setError('Error al cargar médicos');
|
||||
toast.error('Error al cargar médicos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const doctors = useMemo(() => {
|
||||
const medical = employees.filter((d) => isMedicalTitle(d.job_title));
|
||||
return medical.length > 0 ? medical : employees;
|
||||
}, [employees]);
|
||||
|
||||
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||
|
||||
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
||||
|
||||
const savePct = async (d: Doctor) => {
|
||||
const raw = pctDraft[d.id];
|
||||
const pct = parseFloat(raw ?? String(d.commission_pct ?? 0));
|
||||
if (Number.isNaN(pct) || pct < 0 || pct > 100) {
|
||||
toast.error('Porcentaje inválido (0-100)');
|
||||
return;
|
||||
}
|
||||
setSavingPct(d.id);
|
||||
try {
|
||||
await odooApi.updateDoctor(d.id, { commission_pct: pct });
|
||||
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, commission_pct: pct } : e)));
|
||||
setPctDraft((prev) => { const n = { ...prev }; delete n[d.id]; return n; });
|
||||
toast.success(`Comisión de ${d.name}: ${pct}%`);
|
||||
} catch {
|
||||
toast.error('No se pudo guardar la comisión');
|
||||
} finally {
|
||||
setSavingPct(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Médicos" subtitle="Equipo médico">
|
||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-14 w-full" />
|
||||
) : doctors.length === 0 ? (
|
||||
<EmptyState title="Sin médicos" subtitle="No se encontraron médicos en el equipo." icon={<Stethoscope 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">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puesto</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{doctors.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{d.name}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="default" className="inline-flex items-center">
|
||||
<Briefcase size={12} className="mr-1" />
|
||||
{d.job_title || 'Sin puesto'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
||||
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
||||
{d.work_phone || '-'}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell">
|
||||
{d.work_email ? (
|
||||
<a href={`mailto:${d.work_email}`} className="hover:text-theme-heading">
|
||||
<Mail size={14} className="inline mr-1 text-theme-muted" />
|
||||
{d.work_email}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2 max-w-[170px]">
|
||||
<Percent size={14} className="text-theme-muted shrink-0" />
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={pctValue(d)}
|
||||
onChange={(e) => setPctDraft((prev) => ({ ...prev, [d.id]: e.target.value }))}
|
||||
className="max-w-[92px]"
|
||||
/>
|
||||
<Button size="sm" variant="outline" loading={savingPct === d.id} onClick={() => savePct(d)} title="Guardar %">
|
||||
<Save size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{doctors.map((d) => (
|
||||
<MobileCard
|
||||
key={d.id}
|
||||
title={d.name}
|
||||
subtitle={
|
||||
<Badge variant="default" className="inline-flex items-center">
|
||||
<Briefcase size={12} className="mr-1" />
|
||||
{d.job_title || 'Sin puesto'}
|
||||
</Badge>
|
||||
}
|
||||
rows={[
|
||||
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||
{ label: 'Email', value: d.work_email || '-' },
|
||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Medicos;
|
||||
265
frontend/src/pages/Monedero.tsx
Normal file
265
frontend/src/pages/Monedero.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, Plus, Minus, Wallet } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Wallet as WalletType } from '../services/odoo';
|
||||
|
||||
type TxType = 'accrual' | 'redemption';
|
||||
|
||||
const Monedero: FC = () => {
|
||||
const [wallets, setWallets] = useState<WalletType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<WalletType | null>(null);
|
||||
const [txType, setTxType] = useState<TxType>('accrual');
|
||||
const [points, setPoints] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getWallets({ search, page, page_size: pageSize });
|
||||
if (res.status === 'success') {
|
||||
setWallets(res.wallets);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar monederos');
|
||||
toast.error('Error al cargar monederos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openModal = (w: WalletType, type: TxType) => {
|
||||
setSelected(w);
|
||||
setTxType(type);
|
||||
setPoints('');
|
||||
setDescription('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!selected) return;
|
||||
const value = parseFloat(points);
|
||||
if (Number.isNaN(value) || value <= 0) {
|
||||
toast.error('Ingresa una cantidad válida de puntos');
|
||||
return;
|
||||
}
|
||||
if (txType === 'redemption' && value > selected.points) {
|
||||
toast.error('No puede redimir más puntos de los disponibles');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.walletTransaction({
|
||||
phone: selected.phone,
|
||||
type: txType,
|
||||
points: value,
|
||||
description,
|
||||
});
|
||||
toast.success(txType === 'accrual' ? 'Puntos acumulados' : 'Puntos redimidos');
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error en transacción');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
||||
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
||||
|
||||
<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 teléfono..."
|
||||
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={5} className="h-14 w-full" />
|
||||
) : wallets.length === 0 ? (
|
||||
<EmptyState title="Sin monederos" subtitle="No se encontraron monederos." icon={<Wallet 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">Paciente</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">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Equivalente MXN</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{wallets.map((w) => (
|
||||
<tr key={w.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{w.patient}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{w.phone}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{w.points} pts</td>
|
||||
<td className="p-3 text-sm text-theme-muted">${w.equivalent_mxn}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openModal(w, 'accrual')}
|
||||
title="Acumular"
|
||||
>
|
||||
<Plus size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openModal(w, 'redemption')}
|
||||
title="Redimir"
|
||||
disabled={w.points <= 0}
|
||||
>
|
||||
<Minus size={16} className="text-rose-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{wallets.map((w) => (
|
||||
<MobileCard
|
||||
key={w.id}
|
||||
title={w.patient}
|
||||
subtitle={w.phone}
|
||||
rows={[
|
||||
{ label: 'Puntos', value: `${w.points} pts` },
|
||||
{ label: 'Equivalente', value: `$${w.equivalent_mxn}` },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'accrual')}>
|
||||
<Plus size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'redemption')} disabled={w.points <= 0}>
|
||||
<Minus size={16} className="text-rose-600" />
|
||||
</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} monederos · página {page} de {totalPages || 1}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<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
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={txType === 'accrual' ? 'Acumular puntos' : 'Redimir puntos'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>Guardar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selected && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Paciente: <span className="font-medium text-theme-heading">{selected.patient}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Puntos disponibles: <span className="font-medium text-theme-heading">{selected.points} pts</span>
|
||||
</p>
|
||||
<Input
|
||||
label="Puntos *"
|
||||
type="number"
|
||||
min={1}
|
||||
value={points}
|
||||
onChange={(e) => setPoints(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Descripción"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monedero;
|
||||
758
frontend/src/pages/Pacientes.tsx
Normal file
758
frontend/src/pages/Pacientes.tsx
Normal file
@@ -0,0 +1,758 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
MobileCard,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
TextArea,
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
|
||||
const genderOptions = [
|
||||
{ value: '', label: 'No especificado' },
|
||||
{ value: 'male', label: 'Masculino' },
|
||||
{ value: 'female', label: 'Femenino' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
const bloodOptions = [
|
||||
{ value: '', label: 'No especificado' },
|
||||
{ value: 'a+', label: 'A+' },
|
||||
{ value: 'a-', label: 'A-' },
|
||||
{ value: 'b+', label: 'B+' },
|
||||
{ value: 'b-', label: 'B-' },
|
||||
{ value: 'ab+', label: 'AB+' },
|
||||
{ value: 'ab-', label: 'AB-' },
|
||||
{ value: 'o+', label: 'O+' },
|
||||
{ value: 'o-', label: 'O-' },
|
||||
];
|
||||
|
||||
const maritalOptions = [
|
||||
{ value: '', label: 'No especificado' },
|
||||
{ value: 'single', label: 'Soltero/a' },
|
||||
{ value: 'married', label: 'Casado/a' },
|
||||
{ value: 'divorced', label: 'Divorciado/a' },
|
||||
{ value: 'widowed', label: 'Viudo/a' },
|
||||
{ value: 'union', label: 'Unión libre' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
const formatGender = (gender?: string | false) => {
|
||||
if (!gender) return 'No especificado';
|
||||
const map: Record<string, string> = {
|
||||
male: 'Masculino',
|
||||
female: 'Femenino',
|
||||
other: 'Otro',
|
||||
};
|
||||
return map[gender] || gender;
|
||||
};
|
||||
|
||||
const formatMarital = (status?: string) => {
|
||||
if (!status) return 'No especificado';
|
||||
const opt = maritalOptions.find((o) => o.value === status);
|
||||
return opt?.label || status;
|
||||
};
|
||||
|
||||
const formatBlood = (bt?: string | false) => {
|
||||
if (!bt || typeof bt !== 'string') return 'No especificado';
|
||||
const opt = bloodOptions.find((o) => o.value === bt.toLowerCase());
|
||||
return opt?.label || bt.toUpperCase();
|
||||
};
|
||||
|
||||
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 [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 [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Patient | null>(null);
|
||||
|
||||
const emptyForm: PatientForm = {
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
blood_type: '',
|
||||
birthplace: '',
|
||||
occupation: '',
|
||||
marital_status: '',
|
||||
emergency_contact: '',
|
||||
emergency_phone: '',
|
||||
home_phone: '',
|
||||
mobile: '',
|
||||
address_notes: '',
|
||||
referred_by: '',
|
||||
patient_comments: '',
|
||||
internal_notes: '',
|
||||
allergies: '',
|
||||
medical_history: '',
|
||||
current_medication: '',
|
||||
medical_notes: '',
|
||||
surgeries_notes: '',
|
||||
children_count: 0,
|
||||
is_pregnant: false,
|
||||
is_breastfeeding: false,
|
||||
uses_contraceptives: false,
|
||||
kidney_problems: false,
|
||||
back_pain: false,
|
||||
heart_disease: false,
|
||||
respiratory_problems: false,
|
||||
blood_pressure: false,
|
||||
diabetes: false,
|
||||
thyroid: false,
|
||||
colitis: false,
|
||||
constipation: false,
|
||||
liver_problems: false,
|
||||
surgeries: false,
|
||||
varicose_veins: false,
|
||||
migraine: false,
|
||||
faints_with_needles: false,
|
||||
};
|
||||
const [form, setForm] = useState<PatientForm>(emptyForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
|
||||
if (res.status === 'success') {
|
||||
setPatients(res.patients);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar pacientes');
|
||||
toast.error('Error al cargar pacientes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openHistory = async (patient: Patient) => {
|
||||
setSelected(patient);
|
||||
setTab('info');
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getPatientHistory(patient.id);
|
||||
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar historial');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (patient: Patient) => {
|
||||
setEditing(patient);
|
||||
setForm({
|
||||
...emptyForm,
|
||||
...patient,
|
||||
birth_date: patient.birth_date || '',
|
||||
gender: patient.gender || '',
|
||||
blood_type: patient.blood_type || '',
|
||||
marital_status: patient.marital_status || '',
|
||||
children_count: patient.children_count ?? 0,
|
||||
is_pregnant: patient.is_pregnant ?? false,
|
||||
is_breastfeeding: patient.is_breastfeeding ?? false,
|
||||
uses_contraceptives: patient.uses_contraceptives ?? false,
|
||||
kidney_problems: patient.kidney_problems ?? false,
|
||||
back_pain: patient.back_pain ?? false,
|
||||
heart_disease: patient.heart_disease ?? false,
|
||||
respiratory_problems: patient.respiratory_problems ?? false,
|
||||
blood_pressure: patient.blood_pressure ?? false,
|
||||
diabetes: patient.diabetes ?? false,
|
||||
thyroid: patient.thyroid ?? false,
|
||||
colitis: patient.colitis ?? false,
|
||||
constipation: patient.constipation ?? false,
|
||||
liver_problems: patient.liver_problems ?? false,
|
||||
surgeries: patient.surgeries ?? false,
|
||||
varicose_veins: patient.varicose_veins ?? false,
|
||||
migraine: patient.migraine ?? false,
|
||||
faints_with_needles: patient.faints_with_needles ?? false,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const updateForm = (patch: Partial<PatientForm>) => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name || !form.phone) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload: Partial<Patient> = {
|
||||
...form,
|
||||
birth_date: form.birth_date || null,
|
||||
gender: form.gender || undefined,
|
||||
blood_type: form.blood_type || undefined,
|
||||
marital_status: form.marital_status || undefined,
|
||||
};
|
||||
if (editing) {
|
||||
await odooApi.updatePatient(editing.id, payload);
|
||||
toast.success('Paciente actualizado');
|
||||
} else {
|
||||
await odooApi.createPatient(payload);
|
||||
toast.success('Paciente creado');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(editing ? 'Error al actualizar paciente' : 'Error al crear paciente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TableHeader = () => (
|
||||
<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 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>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
||||
const InfoRow = ({ label, value, icon: Icon }: { label: string; value: React.ReactNode; icon?: typeof User }) => (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">{label}</p>
|
||||
<p className="text-sm font-medium text-theme-heading flex items-start">
|
||||
{Icon && <Icon size={14} className="mr-1.5 mt-0.5 text-theme-muted shrink-0" />}
|
||||
<span className="break-words">{value || '-'}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ClinicalItem = ({ label, value }: { label: string; value?: boolean }) => (
|
||||
<div className="flex items-center justify-between p-2.5 bg-theme-surface border border-theme-border rounded-lg">
|
||||
<span className="text-sm text-theme-heading">{label}</span>
|
||||
{value ? (
|
||||
<CheckCircle2 size={16} className="text-theme-heading" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-[#D6D3D1]" />
|
||||
)}
|
||||
</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)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo paciente
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<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 o teléfono..."
|
||||
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" />
|
||||
) : patients.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin pacientes"
|
||||
subtitle="No se encontraron pacientes."
|
||||
actionLabel="Nuevo paciente"
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<TableHeader />
|
||||
<tbody className="divide-y">
|
||||
{patients.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||
<button onClick={() => openHistory(p)} className="text-left hover:underline">
|
||||
{p.name}
|
||||
</button>
|
||||
</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>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.total_visits}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{p.wallet_points} pts</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openHistory(p)} title="Ver detalle">
|
||||
<User size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{patients.map((p) => (
|
||||
<MobileCard
|
||||
key={p.id}
|
||||
title={p.name}
|
||||
subtitle={p.phone}
|
||||
rows={[
|
||||
{ label: 'Edad', value: p.age ?? '-' },
|
||||
{ label: 'Última visita', value: p.last_visit || '-' },
|
||||
{ label: 'Visitas', value: p.total_visits },
|
||||
{ label: 'Puntos', value: `${p.wallet_points} pts` },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openHistory(p)}>
|
||||
<User size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
|
||||
<Edit2 size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClick={() => openHistory(p)}
|
||||
/>
|
||||
))}
|
||||
</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">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<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 crear/editar */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Actualizar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
|
||||
<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
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Nombre *" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} />
|
||||
<Input label="Teléfono *" value={form.phone} onChange={(e) => updateForm({ phone: e.target.value })} />
|
||||
<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="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 })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<MapPin size={16} className="mr-2 text-theme-muted" /> Datos personales
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Lugar de nacimiento" value={form.birthplace} onChange={(e) => updateForm({ birthplace: e.target.value })} />
|
||||
<Input label="Empleo / Ocupación" value={form.occupation} onChange={(e) => updateForm({ occupation: e.target.value })} />
|
||||
<Select label="Estado civil" options={maritalOptions} value={form.marital_status} onChange={(e) => updateForm({ marital_status: e.target.value })} />
|
||||
<Input label="Recomendado por" value={form.referred_by} onChange={(e) => updateForm({ referred_by: e.target.value })} />
|
||||
<Input label="Contacto de emergencia" value={form.emergency_contact} onChange={(e) => updateForm({ emergency_contact: e.target.value })} />
|
||||
<Input label="Teléfono de emergencia" value={form.emergency_phone} onChange={(e) => updateForm({ emergency_phone: e.target.value })} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TextArea label="Dirección completa" value={form.address_notes} onChange={(e) => updateForm({ address_notes: e.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<Baby size={16} className="mr-2 text-theme-muted" /> Antecedentes gineco-obstétricos
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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={form.is_pregnant}
|
||||
onChange={(e) => updateForm({ is_pregnant: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading">¿Está embarazada?</span>
|
||||
</label>
|
||||
<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={form.is_breastfeeding}
|
||||
onChange={(e) => updateForm({ is_breastfeeding: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading">¿Está lactando?</span>
|
||||
</label>
|
||||
<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={form.uses_contraceptives}
|
||||
onChange={(e) => updateForm({ uses_contraceptives: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading">¿Usa anticonceptivos?</span>
|
||||
</label>
|
||||
<Input
|
||||
label="Número de hijos"
|
||||
type="number"
|
||||
min={0}
|
||||
value={String(form.children_count ?? 0)}
|
||||
onChange={(e) => updateForm({ children_count: parseInt(e.target.value || '0', 10) })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
||||
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Historia clínica
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{[
|
||||
['kidney_problems', 'Problemas de riñón'],
|
||||
['back_pain', 'Dolor de espalda'],
|
||||
['heart_disease', 'Enfermedades cardíacas'],
|
||||
['respiratory_problems', 'Problemas respiratorios'],
|
||||
['blood_pressure', 'Presión arterial'],
|
||||
['diabetes', 'Diabetes'],
|
||||
['thyroid', 'Problemas de tiroides'],
|
||||
['colitis', 'Colitis'],
|
||||
['constipation', 'Estreñimiento'],
|
||||
['liver_problems', 'Problemas de hígado'],
|
||||
['surgeries', 'Cirugías'],
|
||||
['varicose_veins', 'Varices'],
|
||||
['migraine', 'Migraña'],
|
||||
['faints_with_needles', 'Se desmaya con agujas'],
|
||||
].map(([key, label]) => {
|
||||
const checked = !!form[key as keyof PatientForm];
|
||||
return (
|
||||
<label key={key} 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={typeof checked === 'boolean' ? checked : false}
|
||||
onChange={(e) => updateForm({ [key]: e.target.checked } as Partial<PatientForm>)}
|
||||
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
|
||||
/>
|
||||
<span className="text-sm text-theme-heading">{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 mt-4">
|
||||
<TextArea label="Alergias" value={form.allergies} onChange={(e) => updateForm({ allergies: e.target.value })} />
|
||||
<TextArea label="Historial médico" value={form.medical_history} onChange={(e) => updateForm({ medical_history: e.target.value })} />
|
||||
<TextArea label="Medicación actual" value={form.current_medication} onChange={(e) => updateForm({ current_medication: e.target.value })} />
|
||||
<TextArea label="Detalle de cirugías" value={form.surgeries_notes} onChange={(e) => updateForm({ surgeries_notes: e.target.value })} />
|
||||
<TextArea label="Notas médicas adicionales" value={form.medical_notes} onChange={(e) => updateForm({ medical_notes: e.target.value })} />
|
||||
<TextArea label="Comentarios del paciente" value={form.patient_comments} onChange={(e) => updateForm({ patient_comments: e.target.value })} />
|
||||
<TextArea label="Notas internas (solo equipo, no visible para el paciente)" value={form.internal_notes} onChange={(e) => updateForm({ internal_notes: e.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal detalle */}
|
||||
<Modal
|
||||
isOpen={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
title={selected?.name || 'Detalle del paciente'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg whitespace-nowrap ${
|
||||
tab === key ? 'bg-[#1A1A1A] text-white' : 'text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{historyLoading ? (
|
||||
<Skeleton count={4} className="h-16 w-full" />
|
||||
) : tab === 'info' ? (
|
||||
<div className="space-y-4">
|
||||
<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="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} />
|
||||
<InfoRow label="Edad" value={selected?.age} icon={Calendar} />
|
||||
<InfoRow label="Género" value={formatGender(selected?.gender)} icon={User} />
|
||||
<InfoRow label="Tipo sanguíneo" value={formatBlood(selected?.blood_type)} icon={Droplet} />
|
||||
<InfoRow label="Lugar de nacimiento" value={selected?.birthplace} icon={MapPin} />
|
||||
<InfoRow label="Empleo / Ocupación" value={selected?.occupation} icon={Briefcase} />
|
||||
<InfoRow label="Estado civil" value={formatMarital(selected?.marital_status)} icon={Users} />
|
||||
<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="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
|
||||
</div>
|
||||
{selected?.address_notes && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Dirección</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.address_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
{selected?.patient_comments && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Comentarios</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.patient_comments}</p>
|
||||
</div>
|
||||
)}
|
||||
{selected?.internal_notes && (
|
||||
<div className="p-3 bg-amber-50 border border-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-warning-text mb-1">🔒 Notas internas (solo equipo)</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InfoRow label="Número de hijos" value={selected?.children_count} />
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Alergias', value: selected?.allergies },
|
||||
{ label: 'Historial médico', value: selected?.medical_history },
|
||||
{ label: 'Medicación actual', value: selected?.current_medication },
|
||||
{ label: 'Detalle de cirugías', value: selected?.surgeries_notes },
|
||||
{ label: 'Notas médicas adicionales', value: selected?.medical_notes },
|
||||
].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>
|
||||
) : 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} />} />
|
||||
)
|
||||
) : (
|
||||
history && history.sales.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>
|
||||
</div>
|
||||
<p className="text-theme-muted mt-1">{s.date} — {s.state}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Sin ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
interface PatientForm {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
birth_date: string;
|
||||
gender: string;
|
||||
blood_type: string;
|
||||
birthplace: string;
|
||||
occupation: string;
|
||||
marital_status: string;
|
||||
emergency_contact: string;
|
||||
emergency_phone: string;
|
||||
home_phone: string;
|
||||
mobile: string;
|
||||
address_notes: string;
|
||||
referred_by: string;
|
||||
patient_comments: string;
|
||||
internal_notes: string;
|
||||
allergies: string;
|
||||
medical_history: string;
|
||||
current_medication: string;
|
||||
medical_notes: string;
|
||||
surgeries_notes: string;
|
||||
children_count: number;
|
||||
is_pregnant: boolean;
|
||||
is_breastfeeding: boolean;
|
||||
uses_contraceptives: boolean;
|
||||
kidney_problems: boolean;
|
||||
back_pain: boolean;
|
||||
heart_disease: boolean;
|
||||
respiratory_problems: boolean;
|
||||
blood_pressure: boolean;
|
||||
diabetes: boolean;
|
||||
thyroid: boolean;
|
||||
colitis: boolean;
|
||||
constipation: boolean;
|
||||
liver_problems: boolean;
|
||||
surgeries: boolean;
|
||||
varicose_veins: boolean;
|
||||
migraine: boolean;
|
||||
faints_with_needles: boolean;
|
||||
}
|
||||
|
||||
export default Pacientes;
|
||||
223
frontend/src/pages/Pagos.tsx
Normal file
223
frontend/src/pages/Pagos.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
badgeForPaymentState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Payment, type Patient } from '../services/odoo';
|
||||
|
||||
const methods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago', 'Stripe'];
|
||||
|
||||
const methodOptions = methods.map((m) => ({ value: m, label: m }));
|
||||
|
||||
const Pagos: FC = () => {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [patient, setPatient] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState('Efectivo');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [paymentsRes, patientsRes] = await Promise.all([
|
||||
odooApi.getPayments(),
|
||||
odooApi.getPatients(),
|
||||
]);
|
||||
if (paymentsRes.status === 'success') setPayments(paymentsRes.payments);
|
||||
if (patientsRes.status === 'success') setPatients(patientsRes.patients);
|
||||
} catch (err) {
|
||||
setError('Error al cargar pagos');
|
||||
toast.error('Error al cargar pagos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
const value = parseFloat(amount);
|
||||
if (!patient || Number.isNaN(value) || value <= 0) {
|
||||
toast.error('Selecciona paciente e ingresa un monto válido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createPayment({ patient, amount: value, payment_method: method });
|
||||
toast.success('Pago creado');
|
||||
setModalOpen(false);
|
||||
setPatient('');
|
||||
setAmount('');
|
||||
setMethod('Efectivo');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear pago');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = async (id: number) => {
|
||||
try {
|
||||
await odooApi.confirmPayment(id);
|
||||
toast.success('Pago confirmado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al confirmar pago');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: p.name, label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout title="Pagos" subtitle="Transacciones">
|
||||
<PageHeader title="Pagos" subtitle="Crea y confirma pagos">
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo pago
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : payments.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin pagos"
|
||||
subtitle="No hay pagos registrados."
|
||||
actionLabel="Nuevo pago"
|
||||
onAction={() => setModalOpen(true)}
|
||||
icon={<CreditCard 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">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Monto</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Método</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">
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<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">
|
||||
<Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)} title="Confirmar">
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{payments.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> },
|
||||
]}
|
||||
actions={
|
||||
p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)}>
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title="Nuevo pago"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Crear pago</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={patient}
|
||||
onChange={(e) => setPatient(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Monto *"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Método de pago"
|
||||
options={methodOptions}
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagos;
|
||||
129
frontend/src/pages/Productos.tsx
Normal file
129
frontend/src/pages/Productos.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
302
frontend/src/pages/Reportes.tsx
Normal file
302
frontend/src/pages/Reportes.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import Layout from '../components/Layout';
|
||||
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';
|
||||
|
||||
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
||||
|
||||
interface SalesReport {
|
||||
total_sales: number;
|
||||
total_paid: number;
|
||||
total_due: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface AppointmentsReport {
|
||||
total: number;
|
||||
by_state: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CashReport {
|
||||
total: number;
|
||||
by_method: Record<string, number>;
|
||||
}
|
||||
|
||||
const Reportes: FC = () => {
|
||||
const [start, setStart] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
|
||||
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
|
||||
const [cashReport, setCashReport] = useState<CashReport | null>(null);
|
||||
const [commissions, setCommissions] = useState<CommissionRow[]>([]);
|
||||
const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([
|
||||
odooApi.getSalesReport(start, end),
|
||||
odooApi.getAppointmentsReport(start, end),
|
||||
odooApi.getCashReport(today),
|
||||
odooApi.getCommissions(start, end),
|
||||
]);
|
||||
if (salesRes.status === 'success')
|
||||
setSalesReport({
|
||||
total_sales: salesRes.total_sales,
|
||||
total_paid: salesRes.total_paid,
|
||||
total_due: salesRes.total_due,
|
||||
count: salesRes.count,
|
||||
});
|
||||
if (aptRes.status === 'success')
|
||||
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
|
||||
if (cashRes.status === 'success')
|
||||
setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
|
||||
if (commRes.status === 'success') {
|
||||
setCommissions(commRes.commissions || []);
|
||||
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar reportes');
|
||||
toast.error('Error al cargar reportes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [start, end, today]);
|
||||
|
||||
const aptData = appointmentsReport
|
||||
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
const cashData = cashReport
|
||||
? 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,
|
||||
]);
|
||||
}
|
||||
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>
|
||||
</PageHeader>
|
||||
|
||||
<Card className="mb-6">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||
<Input
|
||||
label="Inicio"
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
<Input
|
||||
label="Fin"
|
||||
type="date"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
<Input
|
||||
label="Día efectivo"
|
||||
type="date"
|
||||
value={today}
|
||||
onChange={(e) => setToday(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : salesReport ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Ventas</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_sales}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Pagado</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_paid}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Por cobrar</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_due}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Cantidad</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{salesReport.count}</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por estado</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
) : aptData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay citas en el periodo." icon={<BarChart3 size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={aptData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
<Bar dataKey="value" fill="#57534e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Efectivo por método</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
) : cashData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay pagos registrados para el día." icon={<BarChart3 size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={cashData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
|
||||
{cashData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 sm:mt-6">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Comisiones por médico</h3>
|
||||
<p className="text-xs text-theme-muted">Comisión = % del médico sobre la venta de artículos recetados por él.</p>
|
||||
</div>
|
||||
<div className="text-sm text-theme-muted">
|
||||
Base recetada: <span className="font-semibold text-theme-heading">${commTotals.base.toLocaleString('es-MX')}</span>
|
||||
{' · '}Comisión total: <span className="font-semibold text-theme-heading">${commTotals.commission.toLocaleString('es-MX')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : commissions.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">No hay ventas de artículos recetados en el periodo. Marca las líneas como “Artículo recetado” al crear la venta y asigna el % en Médicos.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">% Comisión</th>
|
||||
<th className="text-right px-4 py-3">Artículos</th>
|
||||
<th className="text-right px-4 py-3">Ventas</th>
|
||||
<th className="text-right px-4 py-3">Base recetada</th>
|
||||
<th className="text-right px-4 py-3">Comisión</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{commissions.map((c) => (
|
||||
<tr key={c.doctor_id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{c.doctor}</p>
|
||||
<p className="text-xs text-theme-muted">{c.job_title || '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{c.commission_pct}%</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.items}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.sales}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">${c.base.toLocaleString('es-MX')}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-theme-heading">${c.commission.toLocaleString('es-MX')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reportes;
|
||||
365
frontend/src/pages/Servicios.tsx
Normal file
365
frontend/src/pages/Servicios.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star, Edit2, Check, X, Plus, Clock, Tag, Package } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
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 [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({
|
||||
name: '',
|
||||
code: '',
|
||||
category: '',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '',
|
||||
description: '',
|
||||
color: '#78716c',
|
||||
service_group: '',
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getServices();
|
||||
if (res.status === 'success') setServices(res.services);
|
||||
} catch (err) {
|
||||
setError('Error al cargar servicios');
|
||||
toast.error('Error al cargar servicios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = async (s: Service) => {
|
||||
try {
|
||||
await odooApi.updateService(s.id, { is_favorite: !s.is_favorite });
|
||||
toast.success('Favorito actualizado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar favorito');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
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 savePrice = 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;
|
||||
}
|
||||
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');
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar servicio');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const create = 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');
|
||||
return;
|
||||
}
|
||||
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: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear servicio');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
return (
|
||||
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-48 w-full" />)}
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle="No hay servicios registrados."
|
||||
actionLabel="Nuevo servicio"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<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" />
|
||||
{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">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-xl p-2.5" style={{ backgroundColor: 'var(--success)' }}>
|
||||
<Package size={14} className="shrink-0 mt-0.5" style={{ color: 'var(--success-text)' }} />
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--success-text)' }}>
|
||||
Paquete {s.package_sessions} sesiones: {formatCurrency(s.package_price ?? 0)}
|
||||
</p>
|
||||
{s.package_notes && <p className="text-[10px]" style={{ color: 'var(--success-text)' }}>{s.package_notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nuevo servicio"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Crear servicio</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Código"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Categoría"
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Precio sesión *"
|
||||
type="number"
|
||||
value={form.price}
|
||||
onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Duración (min)"
|
||||
type="number"
|
||||
value={form.duration_min}
|
||||
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Precio paquete"
|
||||
type="number"
|
||||
value={form.package_price}
|
||||
onChange={(e) => setForm({ ...form, package_price: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Sesiones en paquete"
|
||||
type="number"
|
||||
value={form.package_sessions}
|
||||
onChange={(e) => setForm({ ...form, package_sessions: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Notas del paquete"
|
||||
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 })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Descripción"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Servicios;
|
||||
265
frontend/src/pages/Usuarios.tsx
Normal file
265
frontend/src/pages/Usuarios.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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;
|
||||
631
frontend/src/pages/Ventas.tsx
Normal file
631
frontend/src/pages/Ventas.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
badgeForSaleState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
|
||||
interface LineForm {
|
||||
service_id: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
price_unit: string;
|
||||
is_prescribed: boolean;
|
||||
prescribed_by_id: string;
|
||||
}
|
||||
|
||||
const emptyLine = (): LineForm => ({
|
||||
service_id: '', description: '', quantity: '1', price_unit: '',
|
||||
is_prescribed: false, prescribed_by_id: '',
|
||||
});
|
||||
|
||||
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||
|
||||
const Ventas: FC = () => {
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [payOpen, setPayOpen] = useState<Sale | null>(null);
|
||||
const [patientId, setPatientId] = useState('');
|
||||
const [lines, setLines] = useState<LineForm[]>([
|
||||
emptyLine(),
|
||||
]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [payMethod, setPayMethod] = useState('Efectivo');
|
||||
const [payAmount, setPayAmount] = useState('');
|
||||
const [refundOpen, setRefundOpen] = useState<Sale | null>(null);
|
||||
const [refunding, setRefunding] = useState(false);
|
||||
const [refundAmount, setRefundAmount] = useState('');
|
||||
const [refundReason, setRefundReason] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
|
||||
const loadSales = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getSales({ search, page, page_size: pageSize });
|
||||
if (res.status === 'success') {
|
||||
setSales(res.sales);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar ventas');
|
||||
toast.error('Error al cargar ventas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
|
||||
const loadRefs = useCallback(async () => {
|
||||
try {
|
||||
const servicesRes = await odooApi.getServices();
|
||||
if (servicesRes.status === 'success') setServices(servicesRes.services);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
try {
|
||||
const doctorsRes = await odooApi.getDoctors();
|
||||
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRefs();
|
||||
}, [loadRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSales();
|
||||
}, [loadSales]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
// 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 addLine = () =>
|
||||
setLines([...lines, emptyLine()]);
|
||||
|
||||
const removeLine = (index: number) => setLines(lines.filter((_, i) => i !== index));
|
||||
|
||||
const updateLine = (index: number, field: keyof LineForm, value: string | boolean) => {
|
||||
const next = [...lines];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
setLines(next);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
if (!patientId) {
|
||||
toast.error('Selecciona un paciente');
|
||||
return;
|
||||
}
|
||||
const parsedLines: Omit<SaleLine, 'subtotal'>[] = lines
|
||||
.filter((l) => l.service_id)
|
||||
.map((l): Omit<SaleLine, 'subtotal'> | null => {
|
||||
const qty = parseFloat(l.quantity);
|
||||
const price = parseFloat(l.price_unit);
|
||||
if (Number.isNaN(qty) || qty <= 0 || Number.isNaN(price) || price < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
service_id: parseInt(l.service_id, 10),
|
||||
description: l.description,
|
||||
quantity: qty,
|
||||
price_unit: price,
|
||||
is_prescribed: l.is_prescribed,
|
||||
prescribed_by_id: l.is_prescribed && l.prescribed_by_id ? parseInt(l.prescribed_by_id, 10) : null,
|
||||
};
|
||||
})
|
||||
.filter((l): l is Omit<SaleLine, 'subtotal'> => l !== null);
|
||||
|
||||
if (parsedLines.some((l) => l === null)) {
|
||||
toast.error('Revisa cantidades y precios de las líneas');
|
||||
return;
|
||||
}
|
||||
if (parsedLines.length === 0) {
|
||||
toast.error('Agrega al menos una línea');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createSale({
|
||||
patient_id: parseInt(patientId, 10),
|
||||
lines: parsedLines,
|
||||
});
|
||||
toast.success('Venta creada');
|
||||
setCreateOpen(false);
|
||||
setPatientId('');
|
||||
setLines([emptyLine()]);
|
||||
await loadSales();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear venta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openPay = (sale: Sale) => {
|
||||
setPayOpen(sale);
|
||||
setPayMethod('Efectivo');
|
||||
setPayAmount(String(sale.amount_due));
|
||||
};
|
||||
|
||||
const pay = async () => {
|
||||
if (!payOpen) return;
|
||||
const amount = payAmount ? parseFloat(payAmount) : undefined;
|
||||
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
|
||||
toast.error('Monto inválido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPaying(true);
|
||||
await odooApi.paySale(payOpen.id, { payment_method: payMethod, amount });
|
||||
toast.success('Pago registrado');
|
||||
setPayOpen(null);
|
||||
await loadSales();
|
||||
} catch (err) {
|
||||
toast.error('Error al pagar venta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRefund = (sale: Sale) => {
|
||||
setRefundOpen(sale);
|
||||
setRefundAmount(String(sale.amount_paid));
|
||||
setRefundReason('');
|
||||
};
|
||||
|
||||
const submitRefund = async () => {
|
||||
if (!refundOpen) return;
|
||||
const amount = refundAmount ? parseFloat(refundAmount) : undefined;
|
||||
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
|
||||
toast.error('Monto inválido');
|
||||
return;
|
||||
}
|
||||
if (amount !== undefined && amount > refundOpen.amount_paid) {
|
||||
toast.error(`El monto no puede exceder lo pagado ($${refundOpen.amount_paid})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setRefunding(true);
|
||||
const res = await odooApi.refundSale(refundOpen.id, { amount, reason: refundReason || undefined });
|
||||
const pts = res.result?.points_reversed ?? 0;
|
||||
toast.success(`Devolución registrada${pts > 0 ? ` · ${pts} pts revertidos` : ''}`);
|
||||
setRefundOpen(null);
|
||||
await loadSales();
|
||||
} catch (err) {
|
||||
toast.error('Error al registrar devolución');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setRefunding(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Servicio...' },
|
||||
...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })),
|
||||
];
|
||||
|
||||
const doctorOptions = [
|
||||
{ value: '', label: 'Médico que recetó...' },
|
||||
...doctors.map((d) => ({ value: String(d.id), label: `${d.name} (${d.commission_pct ?? 0}%)` })),
|
||||
];
|
||||
|
||||
const payMethodOptions = paymentMethods.map((m) => ({ value: m, label: m }));
|
||||
|
||||
return (
|
||||
<Layout title="Ventas" subtitle="Órdenes de venta">
|
||||
<PageHeader title="Ventas" subtitle="Crea ventas y registra pagos">
|
||||
<Button variant="outline" onClick={exportSales} disabled={sales.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva venta
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<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 referencia o paciente..."
|
||||
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" />
|
||||
) : sales.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin ventas"
|
||||
subtitle="No hay ventas registradas."
|
||||
actionLabel="Nueva venta"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
icon={<ShoppingCart 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">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Total</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pagado</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">
|
||||
{sales.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
|
||||
<td className="p-3 text-sm font-semibold text-theme-heading">${s.total}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{s.amount_due > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)} title="Pagar">
|
||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||
Devolución
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{sales.map((s) => (
|
||||
<MobileCard
|
||||
key={s.id}
|
||||
title={s.patient}
|
||||
subtitle={s.name}
|
||||
rows={[
|
||||
{ label: 'Total', value: `$${s.total}` },
|
||||
{ label: 'Pagado', value: `$${s.amount_paid}` },
|
||||
{
|
||||
label: 'Estado',
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{s.amount_due > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||
<CreditCard size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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} ventas · página {page} de {totalPages || 1}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<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
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nueva venta"
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Crear venta</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={patientId}
|
||||
onChange={(e) => setPatientId(e.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-theme-muted">Líneas</label>
|
||||
<Button variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Agregar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 items-start">
|
||||
<div className="col-span-12 sm:col-span-4">
|
||||
<Select
|
||||
options={serviceOptions}
|
||||
value={line.service_id}
|
||||
onChange={(e) => {
|
||||
const sid = e.target.value;
|
||||
const svc = services.find((s) => String(s.id) === sid);
|
||||
updateLine(i, 'service_id', sid);
|
||||
if (svc) {
|
||||
updateLine(i, 'price_unit', String(svc.price));
|
||||
updateLine(i, 'description', svc.name);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Descripción"
|
||||
value={line.description}
|
||||
onChange={(e) => updateLine(i, 'description', e.target.value)}
|
||||
className="col-span-12 sm:col-span-4"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Cant"
|
||||
value={line.quantity}
|
||||
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
|
||||
className="col-span-4 sm:col-span-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Precio"
|
||||
value={line.price_unit}
|
||||
onChange={(e) => updateLine(i, 'price_unit', e.target.value)}
|
||||
className="col-span-6 sm:col-span-2"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLine(i)}
|
||||
className="col-span-2 sm:col-span-1 h-[42px]"
|
||||
title="Eliminar línea"
|
||||
>
|
||||
<Trash2 size={16} className="text-rose-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={line.is_prescribed}
|
||||
onChange={(e) => updateLine(i, 'is_prescribed', e.target.checked)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Artículo recetado
|
||||
</label>
|
||||
{line.is_prescribed && (
|
||||
<Select
|
||||
options={doctorOptions}
|
||||
value={line.prescribed_by_id}
|
||||
onChange={(e) => updateLine(i, 'prescribed_by_id', e.target.value)}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!payOpen}
|
||||
onClose={() => setPayOpen(null)}
|
||||
title={`Pagar ${payOpen?.name || ''}`}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPayOpen(null)}>Cancelar</Button>
|
||||
<Button onClick={pay} loading={paying}>Registrar pago</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{payOpen && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Paciente: <span className="font-medium text-theme-heading">{payOpen.patient}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total: <span className="font-medium text-theme-heading">${payOpen.total}</span>
|
||||
{' · '}
|
||||
Por pagar: <span className="font-medium text-theme-heading">${payOpen.amount_due}</span>
|
||||
</p>
|
||||
<Select
|
||||
label="Método de pago"
|
||||
options={payMethodOptions}
|
||||
value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Monto"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!refundOpen}
|
||||
onClose={() => setRefundOpen(null)}
|
||||
title={`Devolución ${refundOpen?.name || ''}`}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setRefundOpen(null)}>Cancelar</Button>
|
||||
<Button variant="danger" onClick={submitRefund} loading={refunding}>Confirmar devolución</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{refundOpen && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Paciente: <span className="font-medium text-theme-heading">{refundOpen.patient}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total: <span className="font-medium text-theme-heading">${refundOpen.total}</span>
|
||||
{' · '}
|
||||
Pagado: <span className="font-medium text-theme-heading">${refundOpen.amount_paid}</span>
|
||||
</p>
|
||||
<div className="rounded-lg bg-amber-50 border border-theme-bg px-3 py-2 text-xs text-amber-800">
|
||||
La devolución reduce el monto pagado y revierte los puntos del monedero en proporción ($10 = 1 pt). Deja el monto vacío o igual a lo pagado para devolución total.
|
||||
</div>
|
||||
<Input
|
||||
label="Monto a devolver"
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={refundOpen.amount_paid}
|
||||
step={0.01}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Motivo"
|
||||
placeholder="Motivo de la devolución..."
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ventas;
|
||||
501
frontend/src/pages/WacrmLeads.tsx
Normal file
501
frontend/src/pages/WacrmLeads.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Target,
|
||||
Search,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Circle,
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreHorizontal,
|
||||
User,
|
||||
ArrowRightCircle,
|
||||
} 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';
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'open', label: 'Abierto' },
|
||||
{ value: 'won', label: 'Ganado' },
|
||||
{ value: 'lost', label: 'Perdido' },
|
||||
];
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'won':
|
||||
return 'success';
|
||||
case 'lost':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'won':
|
||||
return <CheckCircle size={14} className="mr-1" />;
|
||||
case 'lost':
|
||||
return <XCircle size={14} className="mr-1" />;
|
||||
default:
|
||||
return <Circle size={14} className="mr-1" />;
|
||||
}
|
||||
};
|
||||
|
||||
const currencySymbol = (currency: string) => {
|
||||
if (currency === 'MXN') return '$';
|
||||
if (currency === 'USD') return 'US$';
|
||||
return currency;
|
||||
};
|
||||
|
||||
const WacrmLeads: FC = () => {
|
||||
const [leads, setLeads] = useState<WacrmLead[]>([]);
|
||||
const [pipelines, setPipelines] = useState<WacrmPipeline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [pipelineId, setPipelineId] = useState('');
|
||||
const [view, setView] = useState<'list' | 'kanban'>('kanban');
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
|
||||
const [stageModalOpen, setStageModalOpen] = useState(false);
|
||||
const [movingStage, setMovingStage] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [leadsRes, pipelinesRes] = await Promise.all([
|
||||
odooApi.getWacrmLeads(search || undefined, status || undefined, pipelineId || undefined),
|
||||
odooApi.getWacrmPipelines(),
|
||||
]);
|
||||
if (leadsRes.status === 'success') setLeads(leadsRes.leads);
|
||||
if (pipelinesRes.status === 'success') setPipelines(pipelinesRes.pipelines);
|
||||
setLastSync(new Date());
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar leads');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, status, pipelineId]);
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
setSyncing(true);
|
||||
const res = await odooApi.syncWacrm();
|
||||
if (res.status === 'success') {
|
||||
toast.success(`Sincronizado: ${res.result.deals || 0} deals`);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al sincronizar con WACRM');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (lead: WacrmLead, newStatus: 'open' | 'won' | 'lost') => {
|
||||
try {
|
||||
const res = await odooApi.updateWacrmLeadStatus(lead.id, { status: newStatus });
|
||||
if (res.status === 'success') {
|
||||
toast.success(`Lead marcado como ${newStatus}`);
|
||||
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar lead');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const changeStage = async (lead: WacrmLead, stageExternalId: string) => {
|
||||
try {
|
||||
setMovingStage(stageExternalId);
|
||||
const res = await odooApi.updateWacrmLeadStatus(lead.id, { stage_id: stageExternalId });
|
||||
if (res.status === 'success') {
|
||||
toast.success('Etapa actualizada');
|
||||
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||
setStageModalOpen(false);
|
||||
setSelectedLead(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cambiar etapa');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setMovingStage(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Auto-sync cada 15 segundos
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
load();
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
const pipelineOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todos los pipelines' }, ...pipelines.map((p) => ({ value: p.external_id, label: p.name }))],
|
||||
[pipelines]
|
||||
);
|
||||
|
||||
const activePipeline = useMemo(
|
||||
() => pipelines.find((p) => p.external_id === pipelineId) || pipelines[0],
|
||||
[pipelines, pipelineId]
|
||||
);
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (!activePipeline) return leads;
|
||||
return leads.filter((l) => l.pipeline_id === activePipeline.id);
|
||||
}, [leads, activePipeline]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const open = leads.filter((l) => l.status === 'open');
|
||||
const won = leads.filter((l) => l.status === 'won');
|
||||
const lost = leads.filter((l) => l.status === 'lost');
|
||||
return {
|
||||
total: leads.length,
|
||||
openValue: open.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||
wonValue: won.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||
lostValue: lost.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||
openCount: open.length,
|
||||
wonCount: won.length,
|
||||
lostCount: lost.length,
|
||||
};
|
||||
}, [leads]);
|
||||
|
||||
const renderList = () => (
|
||||
<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">Lead</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Contacto</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pipeline / Etapa</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Valor</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">
|
||||
{filteredLeads.map((l) => (
|
||||
<tr key={l.external_id} className="hover:bg-theme-bg">
|
||||
<td className="p-3">
|
||||
<p className="text-sm font-medium text-theme-heading">{l.title}</p>
|
||||
<p className="text-xs text-theme-muted line-clamp-1">{l.notes || 'Sin notas'}</p>
|
||||
</td>
|
||||
<td className="p-3 hidden sm:table-cell">
|
||||
<p className="text-sm text-theme-heading">{l.contact_name || '—'}</p>
|
||||
<p className="text-xs text-theme-muted">{l.contact_phone || '—'}</p>
|
||||
</td>
|
||||
<td className="p-3 hidden md:table-cell">
|
||||
<p className="text-xs text-theme-muted">{l.pipeline || '—'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedLead(l);
|
||||
setStageModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium hover:opacity-80 transition"
|
||||
style={{ backgroundColor: `${l.stage_color}20`, color: l.stage_color }}
|
||||
>
|
||||
{l.stage || '—'} <ArrowRightCircle size={12} className="ml-1" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p className="text-sm font-medium text-theme-heading">
|
||||
{currencySymbol(l.currency)}{l.value.toLocaleString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={statusBadge(l.status)}>
|
||||
{statusIcon(l.status)}
|
||||
{l.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{l.status !== 'won' && (
|
||||
<button
|
||||
onClick={() => updateStatus(l, 'won')}
|
||||
className="p-1.5 text-theme-heading hover:bg-theme-success rounded"
|
||||
title="Marcar ganado"
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
{l.status !== 'lost' && (
|
||||
<button
|
||||
onClick={() => updateStatus(l, 'lost')}
|
||||
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded"
|
||||
title="Marcar perdido"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
{l.status !== 'open' && (
|
||||
<button
|
||||
onClick={() => updateStatus(l, 'open')}
|
||||
className="p-1.5 text-theme-muted hover:bg-theme-bg rounded"
|
||||
title="Reabrir"
|
||||
>
|
||||
<Circle size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderKanban = () => {
|
||||
if (!activePipeline) return <EmptyState title="Sin pipeline" subtitle="Selecciona un pipeline para ver el Kanban." />;
|
||||
return (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex gap-4 min-w-max">
|
||||
{activePipeline.stages.map((stage) => {
|
||||
const stageLeads = filteredLeads.filter((l) => l.stage_id === stage.id);
|
||||
const stageValue = stageLeads.reduce((sum, l) => sum + (l.value || 0), 0);
|
||||
return (
|
||||
<div key={stage.external_id} className="w-72 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||
<h3 className="text-sm font-semibold text-theme-heading">{stage.name}</h3>
|
||||
<span className="text-xs text-theme-muted bg-theme-bg px-1.5 py-0.5 rounded-full">{stageLeads.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{stageLeads.map((l) => (
|
||||
<div key={l.external_id} style={{ borderLeftColor: stage.color }}>
|
||||
<Card className="border-l-4">
|
||||
<Card.Body className="p-3">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<p className="text-sm font-medium text-theme-heading line-clamp-2">{l.title}</p>
|
||||
<Badge variant={statusBadge(l.status)} className="text-[10px] px-1.5 py-0">
|
||||
{statusIcon(l.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-theme-heading">
|
||||
{currencySymbol(l.currency)}{l.value.toLocaleString()}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedLead(l);
|
||||
setStageModalOpen(true);
|
||||
}}
|
||||
className="p-1 text-theme-muted hover:text-theme-heading hover:bg-theme-bg rounded"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{l.status !== 'won' && (
|
||||
<button
|
||||
onClick={() => updateStatus(l, 'won')}
|
||||
className="flex-1 py-1 text-[10px] font-medium text-theme-heading bg-theme-success hover:bg-theme-success rounded"
|
||||
>
|
||||
Ganar
|
||||
</button>
|
||||
)}
|
||||
{l.status !== 'lost' && (
|
||||
<button
|
||||
onClick={() => updateStatus(l, 'lost')}
|
||||
className="flex-1 py-1 text-[10px] font-medium text-rose-700 bg-rose-50 hover:bg-rose-100 rounded"
|
||||
>
|
||||
Perder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
{stageLeads.length === 0 && (
|
||||
<div className="text-center py-6 text-xs text-theme-muted border border-dashed border-theme-border-strong rounded-lg">
|
||||
Sin leads en esta etapa
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{stageValue > 0 && (
|
||||
<p className="mt-2 text-xs text-theme-muted text-right">
|
||||
Total: {currencySymbol(activePipeline.stages[0]?.color ? 'MXN' : 'MXN')}{stageValue.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="WACRM — Leads" subtitle="Deals y oportunidades del CRM">
|
||||
{/* Métricas */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4 sm:mb-6">
|
||||
<Card>
|
||||
<Card.Body className="flex items-center gap-3">
|
||||
<div className="p-2 bg-theme-bg rounded-lg">
|
||||
<Target size={20} className="text-theme-heading" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Total leads</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{stats.total}</p>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<TrendingUp size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Valor abierto</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">${stats.openValue.toLocaleString()}</p>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body className="flex items-center gap-3">
|
||||
<div className="p-2 bg-theme-success rounded-lg">
|
||||
<CheckCircle size={20} className="text-theme-heading" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Ganados</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{stats.wonCount} <span className="text-xs font-normal text-theme-muted">(${stats.wonValue.toLocaleString()})</span></p>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body className="flex items-center gap-3">
|
||||
<div className="p-2 bg-rose-50 rounded-lg">
|
||||
<XCircle size={20} className="text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Perdidos</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{stats.lostCount}</p>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar lead o contacto..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
options={statusOptions}
|
||||
className="w-full lg:w-44"
|
||||
/>
|
||||
<Select
|
||||
value={pipelineId}
|
||||
onChange={(e) => setPipelineId(e.target.value)}
|
||||
options={pipelineOptions}
|
||||
className="w-full lg:w-52"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-lg border border-theme-border-strong overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'kanban' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
|
||||
>
|
||||
<LayoutGrid size={16} /> Kanban
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'list' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
|
||||
>
|
||||
<List size={16} /> Lista
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={sync} loading={syncing}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Sincronizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastSync && (
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
Última sincronización: {lastSync.toLocaleTimeString()} · se actualiza automáticamente cada 15s
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && leads.length === 0 ? (
|
||||
<Skeleton count={6} className="h-14 w-full" />
|
||||
) : filteredLeads.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin leads"
|
||||
subtitle="No hay deals sincronizados. Presiona Sincronizar para traer datos de WACRM."
|
||||
icon={<Target size={28} />}
|
||||
actionLabel="Sincronizar"
|
||||
onAction={sync}
|
||||
/>
|
||||
) : view === 'list' ? (
|
||||
renderList()
|
||||
) : (
|
||||
renderKanban()
|
||||
)}
|
||||
</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>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WacrmLeads;
|
||||
452
frontend/src/pages/WacrmMessages.tsx
Normal file
452
frontend/src/pages/WacrmMessages.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
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 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';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const WacrmMessages: FC = () => {
|
||||
const [conversations, setConversations] = useState<WacrmConversation[]>([]);
|
||||
const [messages, setMessages] = useState<WacrmMessage[]>([]);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<WacrmConversation | null>(null);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [sendingReply, setSendingReply] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('skeen_current_agent_id') || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Refs para evitar loops y dependency churn
|
||||
const selectedRef = useRef<WacrmConversation | null>(null);
|
||||
const searchRef = useRef(search);
|
||||
const userSelectedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selected;
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current = search;
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentAgentId && typeof window !== 'undefined') {
|
||||
localStorage.setItem('skeen_current_agent_id', currentAgentId);
|
||||
}
|
||||
}, [currentAgentId]);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const res = await odooApi.getWacrmConversations(searchRef.current);
|
||||
if (res.status === 'success') {
|
||||
setConversations(res.conversations);
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected) {
|
||||
const updated = res.conversations.find((c) => c.external_id === currentSelected.external_id);
|
||||
if (updated && updated.external_id !== currentSelected.external_id) {
|
||||
// Solo actualizar si realmente cambió la referencia (datos nuevos)
|
||||
setSelected(updated);
|
||||
}
|
||||
} else if (!userSelectedRef.current && res.conversations.length > 0) {
|
||||
setSelected(res.conversations[0]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar conversaciones');
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(async (conversationId: string) => {
|
||||
try {
|
||||
const res = await odooApi.getWacrmMessages(undefined, conversationId);
|
||||
if (res.status === 'success') {
|
||||
setMessages(res.messages.reverse());
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar mensajes');
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
try {
|
||||
const res = await odooApi.getWacrmMembers();
|
||||
if (res.status === 'success') {
|
||||
setMembers(res.members);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error cargando agentes:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
setSyncing(true);
|
||||
const res = await odooApi.syncWacrm();
|
||||
if (res.status === 'success') {
|
||||
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
|
||||
await loadConversations();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al sincronizar con WACRM');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Carga inicial
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const init = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([loadConversations(), loadMembers()]);
|
||||
if (mounted) setLoading(false);
|
||||
};
|
||||
init();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Cuando cambia la búsqueda, recargar lista pero no forzar selección
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
// Cargar mensajes cuando cambia la conversación seleccionada
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
loadMessages(selected.external_id);
|
||||
}
|
||||
}, [selected, loadMessages]);
|
||||
|
||||
// Auto-sync cada 15 segundos
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadConversations();
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected) {
|
||||
loadMessages(currentSelected.external_id);
|
||||
}
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadConversations, loadMessages]);
|
||||
|
||||
const handleSelect = useCallback((c: WacrmConversation) => {
|
||||
userSelectedRef.current = true;
|
||||
setSelected(c);
|
||||
}, []);
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!selected || !replyText.trim()) return;
|
||||
if (!currentAgentId) {
|
||||
toast.error('Selecciona tu identidad de agente antes de responder');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSendingReply(true);
|
||||
await odooApi.sendWacrmMessage({
|
||||
conversation_id: selected.external_id,
|
||||
text: replyText.trim(),
|
||||
assigned_agent_id: currentAgentId,
|
||||
});
|
||||
toast.success('Mensaje enviado');
|
||||
setReplyText('');
|
||||
await loadMessages(selected.external_id);
|
||||
await loadConversations();
|
||||
} catch (err) {
|
||||
toast.error('Error al enviar mensaje');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSendingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (memberId: string | null) => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
setAssigning(true);
|
||||
await odooApi.assignWacrmConversation(selected.external_id, memberId);
|
||||
toast.success(memberId ? 'Conversación asignada' : 'Conversación desasignada');
|
||||
await loadConversations();
|
||||
setAssignOpen(false);
|
||||
} catch (err) {
|
||||
toast.error('Error al asignar conversación');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const senderIcon = (type: string) => {
|
||||
if (type === 'bot') return <Bot size={14} className="text-theme-heading" />;
|
||||
if (type === 'agent') return <Headphones size={14} className="text-blue-600" />;
|
||||
return <User size={14} className="text-theme-muted" />;
|
||||
};
|
||||
|
||||
const senderLabel = (type: string) => {
|
||||
if (type === 'bot') return 'Sofía (Bot)';
|
||||
if (type === 'agent') return 'Agente';
|
||||
return 'Cliente';
|
||||
};
|
||||
|
||||
const assignedMember = members.find((m) => m.id === selected?.assigned_agent_id);
|
||||
|
||||
return (
|
||||
<Layout title="WACRM — Mensajes" subtitle="Conversaciones de WhatsApp sincronizadas">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between 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 conversación..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentAgentId}
|
||||
onChange={(e) => setCurrentAgentId(e.target.value)}
|
||||
className="appearance-none bg-theme-surface border border-theme-border-strong text-theme-heading text-sm rounded-lg pl-3 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-[#1A1A1A]"
|
||||
>
|
||||
<option value="">Responder como...</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||
</div>
|
||||
<Button onClick={sync} loading={syncing}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Sincronizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-20 w-full" />
|
||||
) : conversations.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin conversaciones"
|
||||
subtitle="No hay conversaciones sincronizadas. Presiona Sincronizar para traer datos de WACRM."
|
||||
icon={<MessageSquare size={28} />}
|
||||
actionLabel="Sincronizar"
|
||||
onAction={sync}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
<Card.Body className="p-0 overflow-y-auto flex-1">
|
||||
<div className="divide-y">
|
||||
{conversations.map((c) => (
|
||||
<button
|
||||
key={c.external_id}
|
||||
onClick={() => handleSelect(c)}
|
||||
className={`w-full text-left p-3 sm:p-4 transition hover:bg-theme-bg ${
|
||||
selected?.external_id === c.external_id ? 'bg-theme-bg' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{c.contact_name || 'Desconocido'}</p>
|
||||
<p className="text-xs text-theme-muted flex items-center mt-0.5">
|
||||
<Phone size={10} className="mr-1" />
|
||||
{c.contact_phone || '—'}
|
||||
</p>
|
||||
</div>
|
||||
{c.unread_count > 0 && (
|
||||
<Badge variant="primary">{c.unread_count}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Mensajes */}
|
||||
<Card className="lg:col-span-2 overflow-hidden flex flex-col">
|
||||
<Card.Body className="p-0 flex flex-col h-full">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="p-4 border-b border-theme-border bg-theme-bg flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">{selected.contact_name || 'Desconocido'}</p>
|
||||
<p className="text-xs text-theme-muted">{selected.contact_phone}</p>
|
||||
{assignedMember && (
|
||||
<p className="text-xs text-blue-600 mt-0.5">
|
||||
Asignado a: {assignedMember.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAssignOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<UserPlus size={14} className="mr-1.5" />
|
||||
{selected.assigned_agent_id ? 'Reasignar' : 'Asignar'}
|
||||
</Button>
|
||||
</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>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div
|
||||
key={m.external_id}
|
||||
className={`flex ${
|
||||
m.sender_type === 'customer' ? 'justify-start' : 'justify-end'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] sm:max-w-[70%] rounded-2xl px-4 py-2.5 text-sm ${
|
||||
m.sender_type === 'customer'
|
||||
? 'bg-theme-surface border border-theme-border-strong text-theme-heading'
|
||||
: 'bg-[#1A1A1A] text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1 opacity-80">
|
||||
{senderIcon(m.sender_type)}
|
||||
<span className="text-[10px] font-medium">{senderLabel(m.sender_type)}</span>
|
||||
</div>
|
||||
<p>{m.content_text}</p>
|
||||
{m.media_url && (
|
||||
<a
|
||||
href={m.media_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs underline mt-1 block truncate"
|
||||
>
|
||||
Ver medio
|
||||
</a>
|
||||
)}
|
||||
<p className="text-[10px] mt-1 opacity-60 text-right">{m.created_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Input de respuesta */}
|
||||
<div className="p-3 border-t border-theme-border bg-theme-bg">
|
||||
<div className="flex items-end gap-2">
|
||||
<Input
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendReply();
|
||||
}
|
||||
}}
|
||||
placeholder={currentAgentId ? 'Escribe tu respuesta...' : 'Selecciona tu identidad de agente'}
|
||||
disabled={!currentAgentId || sendingReply}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendReply}
|
||||
loading={sendingReply}
|
||||
disabled={!currentAgentId || !replyText.trim()}
|
||||
>
|
||||
<Send size={16} className="mr-1.5" />
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
{!currentAgentId && (
|
||||
<p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-theme-muted">Selecciona una conversación</p>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de asignación */}
|
||||
<Modal
|
||||
isOpen={assignOpen}
|
||||
onClose={() => setAssignOpen(false)}
|
||||
title="Asignar conversación"
|
||||
maxWidth="sm"
|
||||
>
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{selected?.assigned_agent_id && (
|
||||
<button
|
||||
onClick={() => handleAssign(null)}
|
||||
disabled={assigning}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-red-100 bg-red-50 hover:bg-red-100 transition text-left"
|
||||
>
|
||||
<X size={18} className="text-red-600" />
|
||||
<span className="text-sm font-medium text-red-700">Desasignar conversación</span>
|
||||
</button>
|
||||
)}
|
||||
{members.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-4 text-center">No hay agentes disponibles</p>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => handleAssign(member.id)}
|
||||
disabled={assigning || selected?.assigned_agent_id === member.id}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition text-left ${
|
||||
selected?.assigned_agent_id === member.id
|
||||
? 'border-blue-200 bg-blue-50'
|
||||
: 'border-theme-border hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-theme-border flex items-center justify-center text-xs font-medium text-theme-heading shrink-0">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{member.name}</p>
|
||||
<p className="text-xs text-theme-muted truncate">{member.role}</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WacrmMessages;
|
||||
Reference in New Issue
Block a user