872 lines
44 KiB
TypeScript
872 lines
44 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react';
|
|
import Layout from '../components/Layout';
|
|
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
|
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
|
import { useGlosario } from '../lib/glosario';
|
|
|
|
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
|
|
|
const hhmm = (h: number) => {
|
|
const hh = Math.floor(h || 0);
|
|
const mm = Math.round(((h || 0) - hh) * 60);
|
|
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
|
};
|
|
|
|
const parseHM = (s: string) => {
|
|
const [h, m] = (s || '').split(':').map((v) => parseInt(v, 10));
|
|
return (Number.isNaN(h) ? 0 : h) + (Number.isNaN(m) ? 0 : m) / 60;
|
|
};
|
|
|
|
type DiaDraft = { works: boolean; from: string; to: string };
|
|
|
|
const diaDraftVacio = (): DiaDraft => ({ works: false, from: '09:00', to: '14:00' });
|
|
|
|
const draftDesdeHorario = (horario?: HorarioDia[]): Record<string, DiaDraft> => {
|
|
const out: Record<string, DiaDraft> = {};
|
|
for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio();
|
|
(horario || []).forEach((h) => {
|
|
out[h.day_of_week] = { works: true, from: hhmm(h.hour_from), to: hhmm(h.hour_to) };
|
|
});
|
|
return out;
|
|
};
|
|
|
|
/** Barra simple de progreso meta vs real del mes. */
|
|
const GoalBar: FC<{ goal: number; real: number }> = ({ goal, real }) => {
|
|
const pct = goal > 0 ? Math.min((real / goal) * 100, 100) : 0;
|
|
const cumplido = goal > 0 && real >= goal;
|
|
return (
|
|
<div className="w-full min-w-[150px]">
|
|
<div className="flex items-center justify-between text-[11px] text-theme-muted mb-1">
|
|
<span className="inline-flex items-center gap-1"><Target size={11} /> Meta del mes</span>
|
|
<span className="tabular-nums">{goal > 0 ? `${fmtNum(pct, 0)}%` : 'sin meta'}</span>
|
|
</div>
|
|
<div className="h-2 w-full rounded-full bg-theme-bg overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${cumplido ? 'bg-emerald-600' : 'bg-theme-accent'}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-[11px] text-theme-muted tabular-nums">
|
|
{fmtMoney(real)}{goal > 0 ? ` de ${fmtMoney(goal)}` : ''}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 fmtNum = (n: number | null | undefined, decimals = 2) =>
|
|
(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: decimals });
|
|
|
|
const fmtMoney = (n: number) =>
|
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
|
|
|
const initials = (name: string) =>
|
|
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
|
|
|
type BadgeVariant = 'default' | 'success' | 'info' | 'pending' | 'cancelled';
|
|
|
|
const CITA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
|
pending: { label: 'Pendiente', variant: 'pending' },
|
|
confirmed: { label: 'Confirmada', variant: 'info' },
|
|
arrived: { label: 'Llegó', variant: 'info' },
|
|
in_progress: { label: 'En progreso', variant: 'info' },
|
|
done: { label: 'Completada', variant: 'success' },
|
|
cancelled: { label: 'Cancelada', variant: 'cancelled' },
|
|
no_show: { label: 'No show', variant: 'cancelled' },
|
|
};
|
|
|
|
const VISITA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
|
en_curso: { label: 'En curso', variant: 'info' },
|
|
completada: { label: 'Completada', variant: 'success' },
|
|
cancelada: { label: 'Cancelada', variant: 'cancelled' },
|
|
};
|
|
|
|
const VENTA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
|
draft: { label: 'Borrador', variant: 'default' },
|
|
confirmed: { label: 'Confirmada', variant: 'info' },
|
|
paid: { label: 'Pagada', variant: 'success' },
|
|
partial: { label: 'Parcial', variant: 'pending' },
|
|
cancelled: { label: 'Cancelada', variant: 'cancelled' },
|
|
};
|
|
|
|
const StateBadge: FC<{ map: Record<string, { label: string; variant: BadgeVariant }>; state: string }> = ({ map, state }) => {
|
|
const meta = map[state] || { label: state, variant: 'default' as BadgeVariant };
|
|
return <Badge variant={meta.variant}>{meta.label}</Badge>;
|
|
};
|
|
|
|
/** Badges de ventas 30d al estilo del sistema legacy:
|
|
* píldora verde "N ventas" + gris "N recomendaciones" y
|
|
* píldoras índigo apiladas: usd / m.n. / tarjeta m.n. */
|
|
const StatBadges: FC<{ d: Doctor }> = ({ d }) => (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
|
<span
|
|
className="inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium text-white"
|
|
style={{ backgroundColor: '#00a65a' }}
|
|
>
|
|
<span className="font-bold">{d.ventas_30d ?? 0}</span> ventas
|
|
</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<span className="block w-fit rounded px-1.5 py-0.5 text-[11px] text-white" style={{ backgroundColor: '#605ca8' }}>
|
|
{d.total_30d_usd == null ? '—' : fmtNum(d.total_30d_usd)} usd
|
|
</span>
|
|
<span className="block w-fit rounded px-1.5 py-0.5 text-[11px] text-white" style={{ backgroundColor: '#605ca8' }}>
|
|
{fmtNum(d.total_30d ?? 0, 0)} m.n.
|
|
</span>
|
|
<span className="block w-fit rounded px-1.5 py-0.5 text-[11px] text-white" style={{ backgroundColor: '#605ca8' }}>
|
|
{fmtNum(d.card_30d ?? 0, 0)} tarjeta m.n.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const Medicos: FC = () => {
|
|
const { term } = useGlosario();
|
|
const [employees, setEmployees] = useState<Doctor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Los dados de baja se cargan siempre: se ocultan en la tabla salvo que el
|
|
// usuario pida verlos (para poder reactivarlos).
|
|
const [mostrarBaja, setMostrarBaja] = useState(false);
|
|
const [savingActive, setSavingActive] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const res = await odooApi.getDoctors({ includeInactive: true });
|
|
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 visibles = mostrarBaja ? employees : employees.filter((d) => d.active !== false);
|
|
const medical = visibles.filter((d) => isMedicalTitle(d.job_title));
|
|
return medical.length > 0 ? medical : visibles;
|
|
}, [employees, mostrarBaja]);
|
|
|
|
const countBaja = employees.filter((d) => d.active === false).length;
|
|
|
|
// Activar/desactivar: un médico dado de baja desaparece de la agenda, del
|
|
// selector de pacientes y del de ventas (el backend lo valida también).
|
|
const toggleActivo = async (d: Doctor) => {
|
|
const nuevo = d.active === false;
|
|
const mensaje = nuevo
|
|
? `¿Reactivar a ${d.name}? Volverá a aparecer en la agenda y en las asignaciones.`
|
|
: `¿Dar de baja a ${d.name}? Dejará de aparecer en la agenda y no se le podrán asignar pacientes ni ventas. Su historial se conserva.`;
|
|
if (!window.confirm(mensaje)) return;
|
|
try {
|
|
setSavingActive(d.id);
|
|
await odooApi.updateDoctor(d.id, { active: nuevo });
|
|
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, active: nuevo } : e)));
|
|
toast.success(nuevo ? `${d.name} reactivado` : `${d.name} dado de baja`);
|
|
} catch (err) {
|
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
toast.error(msg || 'No se pudo actualizar el estado');
|
|
} finally {
|
|
setSavingActive(null);
|
|
}
|
|
};
|
|
|
|
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
|
const [savingPct, setSavingPct] = useState<number | null>(null);
|
|
|
|
// Modal de detalle del médico
|
|
const [selected, setSelected] = useState<Doctor | null>(null);
|
|
const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info');
|
|
const [hist, setHist] = useState<DoctorHistory | null>(null);
|
|
const [histLoading, setHistLoading] = useState(false);
|
|
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
|
|
|
|
// Meta mensual (editable en el modal) y horario semanal
|
|
const [goalDraft, setGoalDraft] = useState('');
|
|
const [savingGoal, setSavingGoal] = useState(false);
|
|
const [dias, setDias] = useState<Record<string, DiaDraft>>(diaDraftVacio7());
|
|
const [savingHorario, setSavingHorario] = useState(false);
|
|
|
|
function diaDraftVacio7(): Record<string, DiaDraft> {
|
|
const out: Record<string, DiaDraft> = {};
|
|
for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio();
|
|
return out;
|
|
}
|
|
|
|
const openDoctor = (d: Doctor) => {
|
|
setSelected(d);
|
|
setModalTab('info');
|
|
setHistSection('citas');
|
|
setHist(null);
|
|
setGoalDraft(String(d.monthly_goal ?? 0));
|
|
setDias(draftDesdeHorario(d.horario));
|
|
setHistLoading(true);
|
|
odooApi.getDoctorHistory(d.id)
|
|
.then((res) => { if (res.status === 'success') setHist(res); })
|
|
.catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); })
|
|
.finally(() => setHistLoading(false));
|
|
};
|
|
|
|
const saveGoal = async (d: Doctor) => {
|
|
const meta = parseFloat(goalDraft || '0');
|
|
if (Number.isNaN(meta) || meta < 0) {
|
|
toast.error('Meta inválida');
|
|
return;
|
|
}
|
|
setSavingGoal(true);
|
|
try {
|
|
await odooApi.updateDoctor(d.id, { monthly_goal: meta });
|
|
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, monthly_goal: meta } : e)));
|
|
setSelected((s) => (s && s.id === d.id ? { ...s, monthly_goal: meta } : s));
|
|
toast.success(`Meta de ${d.name}: ${fmtMoney(meta)}`);
|
|
} catch {
|
|
toast.error('No se pudo guardar la meta');
|
|
} finally {
|
|
setSavingGoal(false);
|
|
}
|
|
};
|
|
|
|
const setDia = (day: string, patch: Partial<DiaDraft>) =>
|
|
setDias((prev) => ({ ...prev, [day]: { ...prev[day], ...patch } }));
|
|
|
|
const copiarLunes = () => {
|
|
const lunes = dias['0'];
|
|
setDias((prev) => {
|
|
const out: Record<string, DiaDraft> = {};
|
|
for (let d = 1; d < 7; d++) out[String(d)] = { ...lunes };
|
|
return { ...prev, ...out };
|
|
});
|
|
toast.success('Horario del lunes copiado a todos los días');
|
|
};
|
|
|
|
const saveHorario = async (d: Doctor) => {
|
|
const lineas: HorarioDia[] = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const key = String(i);
|
|
const dia = dias[key];
|
|
if (!dia?.works) continue;
|
|
const hf = parseHM(dia.from);
|
|
const ht = parseHM(dia.to);
|
|
if (!(ht > hf)) {
|
|
toast.error(`${DIAS[i]}: la hora fin debe ser mayor a la hora inicio`);
|
|
return;
|
|
}
|
|
lineas.push({ day_of_week: key, hour_from: hf, hour_to: ht });
|
|
}
|
|
setSavingHorario(true);
|
|
try {
|
|
const res = await odooApi.setDoctorHorario(d.id, lineas);
|
|
setEmployees((prev) => prev.map((e) => (e.id === d.id
|
|
? { ...e, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen }
|
|
: e)));
|
|
setSelected((s) => (s && s.id === d.id
|
|
? { ...s, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen }
|
|
: s));
|
|
toast.success(res.tiene_horario
|
|
? `Horario guardado: ${res.horario_resumen}`
|
|
: 'Horario borrado: la agenda no restringirá horarios para este médico');
|
|
} catch (err) {
|
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
toast.error(msg || 'No se pudo guardar el horario');
|
|
} finally {
|
|
setSavingHorario(false);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Tipo de staff (Doctor / Cosmetóloga): guardado in-place al cambiar el selector
|
|
const [savingTypeId, setSavingTypeId] = useState<number | null>(null);
|
|
const saveStaffType = async (d: Doctor, staff_type: 'doctor' | 'cosmetologa') => {
|
|
if ((d.staff_type ?? 'doctor') === staff_type) return;
|
|
setSavingTypeId(d.id);
|
|
try {
|
|
await odooApi.updateDoctor(d.id, { staff_type });
|
|
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, staff_type } : e)));
|
|
toast.success(`${d.name}: tipo actualizado a ${staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}`);
|
|
} catch {
|
|
toast.error('No se pudo guardar el tipo');
|
|
} finally {
|
|
setSavingTypeId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout title={term('doctor_plural')} subtitle="Equipo médico">
|
|
<PageHeader title={term('doctor_plural')} subtitle="Especialistas de la clínica">
|
|
{countBaja > 0 && (
|
|
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={mostrarBaja}
|
|
onChange={(e) => setMostrarBaja(e.target.checked)}
|
|
className="w-4 h-4 rounded border-theme-border-strong"
|
|
/>
|
|
Mostrar dados de baja ({countBaja})
|
|
</label>
|
|
)}
|
|
</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" />
|
|
) : 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">Tipo</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase" title="Un médico dado de baja no aparece en la agenda ni recibe pacientes o ventas">Estado</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>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase" title="Ventas con líneas recetadas por el médico, últimos 30 días">Últimos 30 días</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden xl:table-cell">Meta del mes</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden xl:table-cell">Horario</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{doctors.map((d) => (
|
|
<tr key={d.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDoctor(d)} title="Ver detalle del médico">
|
|
<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" onClick={(e) => e.stopPropagation()}>
|
|
<Select
|
|
options={[
|
|
{ value: 'doctor', label: 'Doctor' },
|
|
{ value: 'cosmetologa', label: 'Cosmetóloga' },
|
|
]}
|
|
value={d.staff_type ?? 'doctor'}
|
|
onChange={(e) => saveStaffType(d, e.target.value as 'doctor' | 'cosmetologa')}
|
|
disabled={savingTypeId === d.id}
|
|
className="max-w-[150px]"
|
|
/>
|
|
</td>
|
|
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={d.active !== false}
|
|
disabled={savingActive === d.id}
|
|
onClick={() => void toggleActivo(d)}
|
|
title={d.active === false ? 'Dar de alta (reactivar)' : 'Dar de baja'}
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium transition-colors disabled:opacity-50 ${
|
|
d.active === false
|
|
? 'border-rose-300 bg-rose-50 text-rose-700'
|
|
: 'border-emerald-300 bg-brand-mint text-emerald-800'
|
|
}`}
|
|
>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${d.active === false ? 'bg-rose-500' : 'bg-emerald-600'}`} />
|
|
{d.active === false ? 'Baja' : 'Activo'}
|
|
</button>
|
|
</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" onClick={(e) => e.stopPropagation()}>
|
|
<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>
|
|
<td className="p-3 align-top">
|
|
<StatBadges d={d} />
|
|
</td>
|
|
<td className="p-3 align-top hidden xl:table-cell">
|
|
<GoalBar goal={d.monthly_goal ?? 0} real={d.month_total ?? 0} />
|
|
</td>
|
|
<td className="p-3 align-top hidden xl:table-cell">
|
|
{d.tiene_horario ? (
|
|
<span className="text-xs text-theme-heading" title={d.horario_resumen}>
|
|
{d.horario_resumen}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-theme-muted">Sin horario</span>
|
|
)}
|
|
</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: 'Estado',
|
|
value: (
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleActivo(d)}
|
|
className={`rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
d.active === false ? 'border-rose-300 bg-rose-50 text-rose-700' : 'border-emerald-300 bg-brand-mint text-emerald-800'
|
|
}`}
|
|
>
|
|
{d.active === false ? 'Baja' : 'Activo'}
|
|
</button>
|
|
),
|
|
},
|
|
{ label: 'Teléfono', value: d.work_phone || '-' },
|
|
{ label: 'Email', value: d.work_email || '-' },
|
|
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
|
{ label: 'Meta del mes', value: <GoalBar goal={d.monthly_goal ?? 0} real={d.month_total ?? 0} /> },
|
|
{ label: 'Horario', value: d.tiene_horario ? d.horario_resumen : 'Sin horario' },
|
|
{ label: 'Últimos 30 días', value: <StatBadges d={d} /> },
|
|
]}
|
|
actions={
|
|
<Button variant="ghost" size="sm" onClick={() => openDoctor(d)} title="Ver detalle">
|
|
<Eye size={16} className="text-theme-muted" />
|
|
</Button>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Modal detalle del médico */}
|
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="" maxWidth="2xl">
|
|
{selected && (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
|
<div className="flex items-center gap-4 min-w-0">
|
|
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-base font-bold text-theme-heading">
|
|
{initials(selected.name)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h3 className="font-heading text-2xl text-theme-heading truncate">{selected.name}</h3>
|
|
<Badge variant="default" className="inline-flex items-center mt-1">
|
|
<Briefcase size={12} className="mr-1" />
|
|
{selected.job_title || 'Sin puesto'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-5">
|
|
<div className="text-right">
|
|
<p className="text-xs font-medium text-theme-muted uppercase">Pacientes</p>
|
|
<p className="font-heading text-xl text-theme-heading">{fmtNum(selected.patient_count ?? 0, 0)}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs font-medium text-theme-muted uppercase">Citas</p>
|
|
<p className="font-heading text-xl text-theme-heading">{histLoading || !hist ? '…' : fmtNum(hist.totales.citas_total, 0)}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs font-medium text-theme-muted uppercase">Visitas</p>
|
|
<p className="font-heading text-xl text-theme-heading">{histLoading || !hist ? '…' : fmtNum(hist.totales.visitas_total, 0)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
|
{([{ key: 'info', label: 'Info' }, { key: 'horario', label: 'Horario' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
onClick={() => setModalTab(t.key)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
|
modalTab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{modalTab === 'info' ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Resumen de actividad</p>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<div className="p-3 bg-theme-bg rounded-xl">
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-1"><Users size={12} /> Pacientes asignados</p>
|
|
<p className="font-heading text-xl text-theme-heading">{fmtNum(selected.patient_count ?? 0, 0)}</p>
|
|
</div>
|
|
<div className="p-3 bg-theme-bg rounded-xl" title="Ventas con líneas recetadas por el médico, últimos 30 días">
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-1"><ShoppingCart size={12} /> Ventas 30d</p>
|
|
<p className="font-heading text-xl text-theme-heading">{selected.ventas_30d ?? 0}</p>
|
|
</div>
|
|
<div className="p-3 bg-theme-bg rounded-xl" title="Subtotal de líneas recetadas, últimos 30 días">
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-1"><DollarSign size={12} /> Total 30d</p>
|
|
<p className="font-heading text-xl text-theme-heading">{fmtMoney(selected.total_30d ?? 0)}</p>
|
|
</div>
|
|
<div className="p-3 bg-theme-bg rounded-xl" title="Pagado con tarjeta (terminal física), últimos 30 días">
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-1"><CreditCard size={12} /> Tarjeta 30d</p>
|
|
<p className="font-heading text-xl text-theme-heading">{fmtMoney(selected.card_30d ?? 0)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2">
|
|
<span
|
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-[11px] text-white"
|
|
style={{ backgroundColor: '#605ca8' }}
|
|
title="Equivalente en USD al TC vigente"
|
|
>
|
|
{selected.total_30d_usd == null ? '—' : `≈ ${fmtNum(selected.total_30d_usd)}`} usd
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Contacto</p>
|
|
<div className="p-4 bg-theme-bg rounded-xl grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-0.5"><Phone size={12} /> Teléfono</p>
|
|
<p className="text-sm font-medium text-theme-heading">{selected.work_phone || '—'}</p>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-0.5"><Mail size={12} /> Email</p>
|
|
<p className="text-sm font-medium text-theme-heading truncate">{selected.work_email || '—'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-theme-muted flex items-center gap-1 mb-0.5"><Percent size={12} /> Comisión</p>
|
|
<p className="text-sm font-medium text-theme-heading">{selected.commission_pct ?? 0}%</p>
|
|
<p className="text-[11px] text-theme-muted">Se edita inline en la tabla</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Meta mensual</p>
|
|
<div className="p-4 bg-theme-bg rounded-xl flex flex-col sm:flex-row sm:items-end gap-4">
|
|
<div className="max-w-[200px]">
|
|
<Input
|
|
label="Meta del mes (m.n.)"
|
|
type="number"
|
|
min="0"
|
|
step="1000"
|
|
value={goalDraft}
|
|
onChange={(e) => setGoalDraft(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
loading={savingGoal}
|
|
onClick={() => saveGoal(selected)}
|
|
title="Guardar meta mensual"
|
|
>
|
|
<Save size={14} className="mr-1.5" />
|
|
Guardar meta
|
|
</Button>
|
|
<div className="flex-1 min-w-[180px]">
|
|
<GoalBar goal={selected.monthly_goal ?? 0} real={selected.month_total ?? 0} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : modalTab === 'horario' ? (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-medium text-theme-muted uppercase">Semana tipo (horario base)</p>
|
|
<p className="text-xs text-theme-muted mt-0.5">
|
|
Marca los días que atiende y sus horas. La agenda sólo ofrecerá esos horarios y rechazará
|
|
citas nuevas fuera de ellos. Sin días marcados, la agenda no restringe.
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={copiarLunes}>
|
|
<Copy size={14} className="mr-1.5" />
|
|
Copiar lunes a todos
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="border border-theme-border rounded-xl divide-y divide-theme-border">
|
|
{DIAS.map((nombre, i) => {
|
|
const key = String(i);
|
|
const dia = dias[key] || diaDraftVacio();
|
|
const activo = dia.works;
|
|
return (
|
|
<div key={key} className="flex flex-wrap items-center gap-3 p-3">
|
|
<label className="flex items-center gap-2 min-w-[140px] cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={activo}
|
|
aria-label={`Trabaja ${nombre}`}
|
|
onChange={(e) => setDia(key, { works: e.target.checked })}
|
|
className="h-4 w-4 rounded border-theme-border-strong accent-theme-accent"
|
|
/>
|
|
<span className={`text-sm font-medium ${activo ? 'text-theme-heading' : 'text-theme-muted'}`}>
|
|
{nombre}
|
|
</span>
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="time"
|
|
aria-label={`Inicio ${nombre}`}
|
|
value={dia.from}
|
|
disabled={!activo}
|
|
onChange={(e) => setDia(key, { from: e.target.value })}
|
|
className="max-w-[130px]"
|
|
/>
|
|
<span className="text-theme-muted text-sm">a</span>
|
|
<Input
|
|
type="time"
|
|
aria-label={`Fin ${nombre}`}
|
|
value={dia.to}
|
|
disabled={!activo}
|
|
onChange={(e) => setDia(key, { to: e.target.value })}
|
|
className="max-w-[130px]"
|
|
/>
|
|
</div>
|
|
{!activo && <span className="text-xs text-theme-muted">No atiende</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-xs text-theme-muted">
|
|
{selected.tiene_horario
|
|
? `Horario actual: ${selected.horario_resumen}`
|
|
: 'Sin horario configurado: la agenda funciona como siempre (8:00 a 19:00).'}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDias(draftDesdeHorario([]))}
|
|
title="Quitar todos los días"
|
|
>
|
|
Limpiar
|
|
</Button>
|
|
<Button loading={savingHorario} onClick={() => saveHorario(selected)}>
|
|
<Clock size={14} className="mr-1.5" />
|
|
Guardar horario
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{histLoading ? (
|
|
<Skeleton count={4} className="h-10 w-full" />
|
|
) : !hist ? (
|
|
<EmptyState title="Sin historial" subtitle="No se pudo cargar la actividad del médico." />
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
|
|
<div className="p-2.5 bg-theme-bg rounded-xl">
|
|
<p className="text-[11px] text-theme-muted flex items-center gap-1 mb-0.5"><CalendarDays size={12} /> Citas totales</p>
|
|
<p className="font-heading text-base text-theme-heading">{fmtNum(hist.totales.citas_total, 0)}</p>
|
|
</div>
|
|
<div className="p-2.5 bg-theme-bg rounded-xl">
|
|
<p className="text-[11px] text-theme-muted flex items-center gap-1 mb-0.5"><ClipboardList size={12} /> Visitas totales</p>
|
|
<p className="font-heading text-base text-theme-heading">{fmtNum(hist.totales.visitas_total, 0)}</p>
|
|
</div>
|
|
<div className="p-2.5 bg-theme-bg rounded-xl">
|
|
<p className="text-[11px] text-theme-muted flex items-center gap-1 mb-0.5"><ShoppingCart size={12} /> Líneas recetadas</p>
|
|
<p className="font-heading text-base text-theme-heading">{fmtNum(hist.totales.ventas_total_lineas, 0)}</p>
|
|
</div>
|
|
<div className="p-2.5 bg-theme-bg rounded-xl">
|
|
<p className="text-[11px] text-theme-muted flex items-center gap-1 mb-0.5"><DollarSign size={12} /> Monto recetado</p>
|
|
<p className="font-heading text-base text-theme-heading">{fmtMoney(hist.totales.monto_total)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{([
|
|
{ key: 'citas', label: 'Citas', icon: <CalendarDays size={14} className="mr-1" /> },
|
|
{ key: 'visitas', label: 'Visitas', icon: <FileText size={14} className="mr-1" /> },
|
|
{ key: 'ventas', label: 'Ventas recetadas', icon: <ShoppingCart size={14} className="mr-1" /> },
|
|
] as const).map((s) => (
|
|
<button
|
|
key={s.key}
|
|
type="button"
|
|
onClick={() => setHistSection(s.key)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition inline-flex items-center ${
|
|
histSection === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
{s.icon}{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{histSection === 'citas' && (
|
|
hist.citas.length === 0 ? (
|
|
<EmptyState title="Sin citas" subtitle="Este médico no tiene citas registradas." />
|
|
) : (
|
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
|
<tr>
|
|
<th className="text-left px-3 py-2">Fecha</th>
|
|
<th className="text-left px-3 py-2">Hora</th>
|
|
<th className="text-left px-3 py-2">Paciente</th>
|
|
<th className="text-left px-3 py-2">Servicio</th>
|
|
<th className="text-left px-3 py-2">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-theme-border">
|
|
{hist.citas.map((c) => (
|
|
<tr key={c.id} className="hover:bg-theme-bg">
|
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{c.date || '—'}</td>
|
|
<td className="px-3 py-2 text-theme-muted">{c.time || '—'}</td>
|
|
<td className="px-3 py-2 text-theme-heading">{c.patient}</td>
|
|
<td className="px-3 py-2 text-theme-muted">{c.service}</td>
|
|
<td className="px-3 py-2"><StateBadge map={CITA_STATE} state={c.state} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{histSection === 'visitas' && (
|
|
hist.visitas.length === 0 ? (
|
|
<EmptyState title="Sin visitas" subtitle="Este médico no tiene visitas registradas." />
|
|
) : (
|
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
|
<tr>
|
|
<th className="text-left px-3 py-2">Fecha</th>
|
|
<th className="text-left px-3 py-2">Paciente</th>
|
|
<th className="text-left px-3 py-2">Motivo</th>
|
|
<th className="text-left px-3 py-2">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-theme-border">
|
|
{hist.visitas.map((v) => (
|
|
<tr key={v.id} className="hover:bg-theme-bg">
|
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{v.date_start || '—'}</td>
|
|
<td className="px-3 py-2 text-theme-heading">{v.patient}</td>
|
|
<td className="px-3 py-2 text-theme-muted">{v.motivo || '—'}</td>
|
|
<td className="px-3 py-2"><StateBadge map={VISITA_STATE} state={v.state} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{histSection === 'ventas' && (
|
|
hist.ventas.length === 0 ? (
|
|
<EmptyState title="Sin ventas recetadas" subtitle="Este médico no tiene líneas recetadas." />
|
|
) : (
|
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
|
<tr>
|
|
<th className="text-left px-3 py-2">Folio</th>
|
|
<th className="text-left px-3 py-2">Fecha</th>
|
|
<th className="text-left px-3 py-2">Paciente</th>
|
|
<th className="text-left px-3 py-2">Servicio / Artículo</th>
|
|
<th className="text-right px-3 py-2">Subtotal</th>
|
|
<th className="text-left px-3 py-2">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-theme-border">
|
|
{hist.ventas.map((l) => (
|
|
<tr key={l.id} className="hover:bg-theme-bg">
|
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{l.venta}</td>
|
|
<td className="px-3 py-2 text-theme-muted whitespace-nowrap">{l.date || '—'}</td>
|
|
<td className="px-3 py-2 text-theme-heading">{l.patient}</td>
|
|
<td className="px-3 py-2 text-theme-muted">{l.item}</td>
|
|
<td className="px-3 py-2 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
|
|
<td className="px-3 py-2"><StateBadge map={VENTA_STATE} state={l.state} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Medicos;
|