Oleada UX 5: Servicios (duplicar, favoritos y orden para POS, mas vendidos) y Medicos (semana tipo con validacion en agenda, meta mensual con progreso)
This commit is contained in:
@@ -1,9 +1,58 @@
|
||||
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 } from 'lucide-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 } from '../services/odoo';
|
||||
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
||||
|
||||
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'];
|
||||
|
||||
@@ -113,16 +162,30 @@ const Medicos: FC = () => {
|
||||
|
||||
// Modal de detalle del médico
|
||||
const [selected, setSelected] = useState<Doctor | null>(null);
|
||||
const [modalTab, setModalTab] = useState<'info' | 'historial'>('info');
|
||||
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); })
|
||||
@@ -130,6 +193,72 @@ const Medicos: FC = () => {
|
||||
.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) => {
|
||||
@@ -193,6 +322,8 @@ const Medicos: FC = () => {
|
||||
<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">
|
||||
@@ -253,6 +384,18 @@ const Medicos: FC = () => {
|
||||
<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>
|
||||
@@ -274,6 +417,8 @@ const Medicos: FC = () => {
|
||||
{ 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={
|
||||
@@ -325,7 +470,7 @@ const Medicos: FC = () => {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{([{ key: 'info', label: 'Info' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
|
||||
{([{ key: 'info', label: 'Info' }, { key: 'horario', label: 'Horario' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
@@ -390,6 +535,115 @@ const Medicos: FC = () => {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user