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:
@@ -392,6 +392,21 @@ const Agenda: FC = () => {
|
|||||||
(b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from)))
|
(b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from)))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Horario semanal del médico: celda fuera de su semana tipo.
|
||||||
|
// Devuelve null si el médico no tiene horario configurado (sin restricción).
|
||||||
|
const fueraDeHorario = (doctorId: number | null, slot: number): string | null => {
|
||||||
|
if (!doctorId) return null;
|
||||||
|
const doc = doctors.find((d) => d.id === doctorId);
|
||||||
|
if (!doc?.tiene_horario || !doc.horario?.length) return null;
|
||||||
|
const dow = String((new Date(`${date}T12:00:00`).getDay() + 6) % 7);
|
||||||
|
const linea = doc.horario.find((h) => h.day_of_week === dow);
|
||||||
|
if (!linea) return `Fuera de horario (no atiende ese día · ${doc.horario_resumen})`;
|
||||||
|
if (slot < linea.hour_from - 1e-6 || slot >= linea.hour_to - 1e-6) {
|
||||||
|
return `Fuera de horario (trabaja ${horaFloatStr(linea.hour_from)}-${horaFloatStr(linea.hour_to)})`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
||||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||||
const handleDrop = async (doctorId: number | null, slot: number) => {
|
const handleDrop = async (doctorId: number | null, slot: number) => {
|
||||||
@@ -1629,6 +1644,7 @@ const Agenda: FC = () => {
|
|||||||
{gridColumns.map((col) => {
|
{gridColumns.map((col) => {
|
||||||
const cell = gridCellAppointments(col.doctorId, slot);
|
const cell = gridCellAppointments(col.doctorId, slot);
|
||||||
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||||
|
const fuera = fueraDeHorario(col.doctorId, slot);
|
||||||
const cellKey = `${col.key}-${slot}`;
|
const cellKey = `${col.key}-${slot}`;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -1636,7 +1652,8 @@ const Agenda: FC = () => {
|
|||||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
||||||
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''}`}
|
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''} ${fuera ? 'opacity-50' : ''}`}
|
||||||
|
title={fuera || undefined}
|
||||||
>
|
>
|
||||||
{bloqueo ? (
|
{bloqueo ? (
|
||||||
<div
|
<div
|
||||||
@@ -1649,15 +1666,17 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : cell.length === 0 ? (
|
) : cell.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => clickCeldaLibre(e, date, slot, col.doctorId)}
|
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
|
||||||
className={`cursor-pointer rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
|
className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
|
||||||
quickFilter === 'libres'
|
fuera
|
||||||
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
|
? 'border-theme-border bg-theme-bg text-theme-muted cursor-not-allowed'
|
||||||
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
|
: quickFilter === 'libres'
|
||||||
|
? 'cursor-pointer bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
|
||||||
|
: 'cursor-pointer border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
|
||||||
}`}
|
}`}
|
||||||
title={`Nueva cita aquí — ${horaFloatStr(slot)}`}
|
title={fuera || `Nueva cita aquí — ${horaFloatStr(slot)}`}
|
||||||
>
|
>
|
||||||
Libre
|
{fuera ? 'Fuera de horario' : 'Libre'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
cell.map((apt) => (
|
cell.map((apt) => (
|
||||||
@@ -1739,6 +1758,7 @@ const Agenda: FC = () => {
|
|||||||
{gridSlots.map((slot) => {
|
{gridSlots.map((slot) => {
|
||||||
const cell = gridCellAppointments(col.doctorId, slot);
|
const cell = gridCellAppointments(col.doctorId, slot);
|
||||||
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||||
|
const fuera = fueraDeHorario(col.doctorId, slot);
|
||||||
const cellKey = `${col.key}-${slot}`;
|
const cellKey = `${col.key}-${slot}`;
|
||||||
const isNowSlot = nowSlot !== null && slot === nowSlot;
|
const isNowSlot = nowSlot !== null && slot === nowSlot;
|
||||||
return (
|
return (
|
||||||
@@ -1747,7 +1767,8 @@ const Agenda: FC = () => {
|
|||||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
||||||
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${fuera ? 'opacity-50' : ''}`}
|
||||||
|
title={fuera || undefined}
|
||||||
>
|
>
|
||||||
{bloqueo ? (
|
{bloqueo ? (
|
||||||
<div
|
<div
|
||||||
@@ -1759,13 +1780,15 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : cell.length === 0 ? (
|
) : cell.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => clickCeldaLibre(e, date, slot, col.doctorId)}
|
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
|
||||||
className={`cursor-pointer rounded border py-1.5 transition-colors ${
|
className={`rounded border py-1.5 transition-colors ${
|
||||||
quickFilter === 'libres'
|
fuera
|
||||||
? 'bg-brand-mint border-emerald-700'
|
? 'cursor-not-allowed border-theme-border bg-theme-bg'
|
||||||
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
|
: quickFilter === 'libres'
|
||||||
|
? 'cursor-pointer bg-brand-mint border-emerald-700'
|
||||||
|
: 'cursor-pointer border-dashed border-theme-border hover:bg-theme-accent-bg'
|
||||||
}`}
|
}`}
|
||||||
title={`Libre ${horaFloatStr(slot)}`}
|
title={fuera || `Libre ${horaFloatStr(slot)}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
cell.map((apt) => (
|
cell.map((apt) => (
|
||||||
|
|||||||
@@ -1,9 +1,58 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useMemo, useState } 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 Layout from '../components/Layout';
|
||||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
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'];
|
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
|
// Modal de detalle del médico
|
||||||
const [selected, setSelected] = useState<Doctor | null>(null);
|
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 [hist, setHist] = useState<DoctorHistory | null>(null);
|
||||||
const [histLoading, setHistLoading] = useState(false);
|
const [histLoading, setHistLoading] = useState(false);
|
||||||
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
|
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) => {
|
const openDoctor = (d: Doctor) => {
|
||||||
setSelected(d);
|
setSelected(d);
|
||||||
setModalTab('info');
|
setModalTab('info');
|
||||||
setHistSection('citas');
|
setHistSection('citas');
|
||||||
setHist(null);
|
setHist(null);
|
||||||
|
setGoalDraft(String(d.monthly_goal ?? 0));
|
||||||
|
setDias(draftDesdeHorario(d.horario));
|
||||||
setHistLoading(true);
|
setHistLoading(true);
|
||||||
odooApi.getDoctorHistory(d.id)
|
odooApi.getDoctorHistory(d.id)
|
||||||
.then((res) => { if (res.status === 'success') setHist(res); })
|
.then((res) => { if (res.status === 'success') setHist(res); })
|
||||||
@@ -130,6 +193,72 @@ const Medicos: FC = () => {
|
|||||||
.finally(() => setHistLoading(false));
|
.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 pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
||||||
|
|
||||||
const savePct = async (d: Doctor) => {
|
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 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">% 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" 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
@@ -253,6 +384,18 @@ const Medicos: FC = () => {
|
|||||||
<td className="p-3 align-top">
|
<td className="p-3 align-top">
|
||||||
<StatBadges d={d} />
|
<StatBadges d={d} />
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -274,6 +417,8 @@ const Medicos: FC = () => {
|
|||||||
{ label: 'Teléfono', value: d.work_phone || '-' },
|
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||||
{ label: 'Email', value: d.work_email || '-' },
|
{ label: 'Email', value: d.work_email || '-' },
|
||||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
{ 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} /> },
|
{ label: 'Últimos 30 días', value: <StatBadges d={d} /> },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
@@ -325,7 +470,7 @@ const Medicos: FC = () => {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
<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
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -390,6 +535,115 @@ const Medicos: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { CircleDollarSign, CreditCard, ShoppingCart, Percent, DollarSign } from 'lucide-react';
|
import { CircleDollarSign, CreditCard, ShoppingCart, Percent, DollarSign, Target } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -157,6 +157,48 @@ const MisVentas: FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Progreso de la meta mensual */}
|
||||||
|
{data.monthly_goal && (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-2">
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading inline-flex items-center gap-2">
|
||||||
|
<Target size={16} /> Meta del mes
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
{data.monthly_goal.from} a {data.monthly_goal.to}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data.monthly_goal.goal > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-full rounded-full bg-theme-bg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
data.monthly_goal.real >= data.monthly_goal.goal ? 'bg-emerald-600' : 'bg-theme-accent'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((data.monthly_goal.real / data.monthly_goal.goal) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-theme-muted">
|
||||||
|
<span className="font-semibold text-theme-heading">{mxn(data.monthly_goal.real)}</span>
|
||||||
|
{' '}de {mxn(data.monthly_goal.goal)} ·{' '}
|
||||||
|
<span className={data.monthly_goal.real >= data.monthly_goal.goal ? 'text-emerald-700 font-semibold' : ''}>
|
||||||
|
{data.monthly_goal.pct ?? 0}%
|
||||||
|
</span>
|
||||||
|
{data.monthly_goal.real >= data.monthly_goal.goal
|
||||||
|
? ' · meta cumplida'
|
||||||
|
: ` · faltan ${mxn(Math.max(data.monthly_goal.goal - data.monthly_goal.real, 0))}`}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Aún no tienes meta asignada. Pide a un administrador que la configure en el módulo Médicos.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Gráfica de ventas por día */}
|
{/* Gráfica de ventas por día */}
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
|||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
||||||
RotateCcw, Keyboard, Lock,
|
RotateCcw, Keyboard, Lock, TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
@@ -57,6 +57,7 @@ const Pos: FC = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [categoria, setCategoria] = useState('');
|
const [categoria, setCategoria] = useState('');
|
||||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||||
|
const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual');
|
||||||
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
|
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
|
||||||
|
|
||||||
// Ticket
|
// Ticket
|
||||||
@@ -106,10 +107,17 @@ const Pos: FC = () => {
|
|||||||
const [ticketMovil, setTicketMovil] = useState(false);
|
const [ticketMovil, setTicketMovil] = useState(false);
|
||||||
|
|
||||||
// ---- Catálogo ----
|
// ---- Catálogo ----
|
||||||
|
// Orden del catálogo de servicios: manual (sequence definida en Servicios)
|
||||||
|
// o por más vendidos de los últimos 90 días.
|
||||||
const loadServices = useCallback(async () => {
|
const loadServices = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setCatalogLoading(true);
|
setCatalogLoading(true);
|
||||||
const res = await odooApi.getServices({ search: search || undefined, category: categoria || undefined, page_size: 200 });
|
const res = await odooApi.getServices({
|
||||||
|
search: search || undefined,
|
||||||
|
category: categoria || undefined,
|
||||||
|
page_size: 200,
|
||||||
|
sort: ordenServicios,
|
||||||
|
});
|
||||||
if (res.status === 'success') setServices(res.services);
|
if (res.status === 'success') setServices(res.services);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Error al cargar servicios');
|
toast.error('Error al cargar servicios');
|
||||||
@@ -117,7 +125,7 @@ const Pos: FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setCatalogLoading(false);
|
setCatalogLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, categoria]);
|
}, [search, categoria, ordenServicios]);
|
||||||
|
|
||||||
const loadItems = useCallback(async () => {
|
const loadItems = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -839,6 +847,27 @@ const Pos: FC = () => {
|
|||||||
{c}
|
{c}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<span className="w-px self-stretch bg-theme-border mx-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOrdenServicios('manual')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
|
||||||
|
ordenServicios === 'manual' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
title="Orden definido en el catálogo de Servicios"
|
||||||
|
>
|
||||||
|
Orden manual
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOrdenServicios('best_sellers')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
|
||||||
|
ordenServicios === 'best_sellers' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
title="Ordenado por ventas de los últimos 90 días"
|
||||||
|
>
|
||||||
|
<TrendingUp size={12} /> Más vendidos
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search } from 'lucide-react';
|
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search, Copy, ChevronUp, ChevronDown, ArrowUpToLine } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -77,6 +77,8 @@ const Servicios: FC = () => {
|
|||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Service | null>(null);
|
const [editing, setEditing] = useState<Service | null>(null);
|
||||||
|
const [duplicando, setDuplicando] = useState(false);
|
||||||
|
const [reordenando, setReordenando] = useState(false);
|
||||||
const [form, setForm] = useState<ServiceForm>(emptyForm);
|
const [form, setForm] = useState<ServiceForm>(emptyForm);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -136,12 +138,14 @@ const Servicios: FC = () => {
|
|||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setDuplicando(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (s: Service) => {
|
const openEdit = (s: Service) => {
|
||||||
setEditing(s);
|
setEditing(s);
|
||||||
|
setDuplicando(false);
|
||||||
setForm({
|
setForm({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
code: s.code || '',
|
code: s.code || '',
|
||||||
@@ -158,6 +162,59 @@ const Servicios: FC = () => {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Duplicar: precarga todo el servicio con el nombre sufijado "(copia)" y el
|
||||||
|
// código marcado como copia (el backend rechaza nombre/código repetidos).
|
||||||
|
const openDuplicate = (s: Service) => {
|
||||||
|
setEditing(null);
|
||||||
|
setDuplicando(true);
|
||||||
|
setForm({
|
||||||
|
name: `${s.name} (copia)`,
|
||||||
|
code: s.code ? `${s.code}-COPIA` : '',
|
||||||
|
category: s.category || 'tratamiento',
|
||||||
|
price: String(s.price),
|
||||||
|
package_price: s.package_price ? String(s.package_price) : '',
|
||||||
|
package_sessions: s.package_sessions ? String(s.package_sessions) : '',
|
||||||
|
package_notes: s.package_notes || '',
|
||||||
|
duration_min: String(s.duration_min),
|
||||||
|
description: s.description || '',
|
||||||
|
color: s.color || '#EDA588',
|
||||||
|
service_group: s.service_group || '',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mueve un servicio una posición arriba/abajo en el orden del POS. */
|
||||||
|
const mover = async (s: Service, dir: -1 | 1) => {
|
||||||
|
const idx = services.findIndex((x) => x.id === s.id);
|
||||||
|
const j = idx + dir;
|
||||||
|
if (idx < 0 || j < 0 || j >= services.length) return;
|
||||||
|
const nuevo = [...services];
|
||||||
|
[nuevo[idx], nuevo[j]] = [nuevo[j], nuevo[idx]];
|
||||||
|
try {
|
||||||
|
setReordenando(true);
|
||||||
|
await odooApi.reorderServices(nuevo.map((x) => x.id));
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cambiar el orden');
|
||||||
|
} finally {
|
||||||
|
setReordenando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Manda el servicio al inicio del orden (primero en el POS). */
|
||||||
|
const mostrarPrimero = async (s: Service) => {
|
||||||
|
try {
|
||||||
|
setReordenando(true);
|
||||||
|
await odooApi.reorderServices([s.id], 'front');
|
||||||
|
toast.success(`"${s.name}" va primero en el POS`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cambiar el orden');
|
||||||
|
} finally {
|
||||||
|
setReordenando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deactivate = async () => {
|
const deactivate = async () => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
if (!window.confirm(`¿Desactivar "${editing.name}"? Dejará de aparecer en el catálogo y en los selectores de citas/ventas.`)) return;
|
if (!window.confirm(`¿Desactivar "${editing.name}"? Dejará de aparecer en el catálogo y en los selectores de citas/ventas.`)) return;
|
||||||
@@ -217,6 +274,7 @@ const Servicios: FC = () => {
|
|||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setDuplicando(false);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
@@ -273,7 +331,7 @@ const Servicios: FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{services.map((s) => (
|
{services.map((s, idx) => (
|
||||||
<Card key={s.id} className="hover:shadow-md transition">
|
<Card key={s.id} className="hover:shadow-md transition">
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
@@ -332,8 +390,44 @@ const Servicios: FC = () => {
|
|||||||
<p className="text-xs text-theme-muted">Precio sesión</p>
|
<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>
|
<p className="text-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
<div className="flex items-center">
|
||||||
<Edit2 size={16} className="text-theme-muted" />
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={reordenando || idx === 0}
|
||||||
|
onClick={() => mover(s, -1)}
|
||||||
|
title="Subir en el orden del POS"
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={reordenando || idx >= services.length - 1}
|
||||||
|
onClick={() => mover(s, 1)}
|
||||||
|
title="Bajar en el orden del POS"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||||
|
<Copy size={15} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||||
|
<Edit2 size={16} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-theme-muted">Orden POS #{(page - 1) * pageSize + idx + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={reordenando}
|
||||||
|
onClick={() => mostrarPrimero(s)}
|
||||||
|
title="Mostrar primero en el POS"
|
||||||
|
>
|
||||||
|
<ArrowUpToLine size={13} className="mr-1 text-theme-muted" />
|
||||||
|
<span className="text-[11px] text-theme-muted">Mostrar primero en POS</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
|
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
|
||||||
@@ -373,8 +467,8 @@ const Servicios: FC = () => {
|
|||||||
{/* Modal crear/editar */}
|
{/* Modal crear/editar */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
onClose={() => { setModalOpen(false); setEditing(null); }}
|
onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}
|
||||||
title={editing ? `Editar: ${editing.name}` : 'Nuevo servicio'}
|
title={editing ? `Editar: ${editing.name}` : duplicando ? 'Duplicar servicio' : 'Nuevo servicio'}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -383,15 +477,28 @@ const Servicios: FC = () => {
|
|||||||
Desactivar
|
Desactivar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); }}>Cancelar</Button>
|
{editing && (
|
||||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear servicio'}</Button>
|
<Button variant="outline" onClick={() => openDuplicate(editing)}>
|
||||||
|
<Copy size={14} className="mr-1.5" />
|
||||||
|
Duplicar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}>Cancelar</Button>
|
||||||
|
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : 'Crear servicio'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{duplicando && (
|
||||||
|
<p className="text-xs rounded-xl px-3 py-2 bg-theme-accent-bg text-theme-heading">
|
||||||
|
Copia precargada. Ajusta el nombre y el precio (el código lleva el sufijo -COPIA).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Nombre *"
|
label="Nombre *"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ export interface Service {
|
|||||||
color: string;
|
color: string;
|
||||||
service_group: string;
|
service_group: string;
|
||||||
is_favorite: boolean;
|
is_favorite: boolean;
|
||||||
|
sequence?: number;
|
||||||
|
sold_90d?: number | null;
|
||||||
genera_monedero?: boolean;
|
genera_monedero?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +268,11 @@ export interface Doctor {
|
|||||||
work_email: string;
|
work_email: string;
|
||||||
commission_pct: number;
|
commission_pct: number;
|
||||||
staff_type?: 'doctor' | 'cosmetologa';
|
staff_type?: 'doctor' | 'cosmetologa';
|
||||||
|
monthly_goal?: number;
|
||||||
|
month_total?: number;
|
||||||
|
horario?: { day_of_week: string; hour_from: number; hour_to: number }[];
|
||||||
|
tiene_horario?: boolean;
|
||||||
|
horario_resumen?: string;
|
||||||
patient_count?: number;
|
patient_count?: number;
|
||||||
recomendaciones_30d?: number;
|
recomendaciones_30d?: number;
|
||||||
ventas_30d?: number;
|
ventas_30d?: number;
|
||||||
@@ -454,6 +461,13 @@ export interface MySalesResponse {
|
|||||||
commission_pct: number;
|
commission_pct: number;
|
||||||
commission_source?: string;
|
commission_source?: string;
|
||||||
commission_estimate: number;
|
commission_estimate: number;
|
||||||
|
monthly_goal?: { goal: number; real: number; pct: number | null; from: string; to: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HorarioDia {
|
||||||
|
day_of_week: string;
|
||||||
|
hour_from: number;
|
||||||
|
hour_to: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CashClosing {
|
export interface CashClosing {
|
||||||
@@ -1352,7 +1366,7 @@ export const odooApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Servicios
|
// Servicios
|
||||||
async getServices(params?: { search?: string; category?: string; page?: number; page_size?: number }): Promise<{ status: string; services: Service[]; total?: number; page?: number; total_pages?: number }> {
|
async getServices(params?: { search?: string; category?: string; page?: number; page_size?: number; sort?: 'manual' | 'best_sellers' }): Promise<{ status: string; services: Service[]; total?: number; page?: number; total_pages?: number; sort?: string }> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`, { params });
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`, { params });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -1372,6 +1386,11 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reorderServices(ids: number[], to?: 'front'): Promise<{ status: string; total: number }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/services/reorder`, { ids, ...(to ? { to } : {}) });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async updateService(id: number, service: Partial<Service>): Promise<{ status: string; service: Service }> {
|
async updateService(id: number, service: Partial<Service>): Promise<{ status: string; service: Service }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service);
|
||||||
return data;
|
return data;
|
||||||
@@ -1383,11 +1402,22 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDoctor(id: number, payload: { commission_pct?: number; staff_type?: 'doctor' | 'cosmetologa' }): Promise<{ status: string; doctor: Doctor }> {
|
async updateDoctor(id: number, payload: { commission_pct?: number; staff_type?: 'doctor' | 'cosmetologa'; monthly_goal?: number }): Promise<{ status: string; doctor: Doctor }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Horario semanal del médico (reemplaza la lista completa)
|
||||||
|
async getDoctorHorario(id: number): Promise<{ status: string; horario: HorarioDia[]; tiene_horario: boolean; horario_resumen: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/horario`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoctorHorario(id: number, horario: HorarioDia[]): Promise<{ status: string; horario: HorarioDia[]; tiene_horario: boolean; horario_resumen: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/horario`, { horario });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async setDoctorCommission(id: number, commission_pct: number): Promise<{ status: string; doctor: { id: number; name: string; commission_pct: number } }> {
|
async setDoctorCommission(id: number, commission_pct: number): Promise<{ status: string; doctor: { id: number; name: string; commission_pct: number } }> {
|
||||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/commission`, { commission_pct });
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/commission`, { commission_pct });
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -265,10 +265,28 @@ class SkeenCita(models.Model):
|
|||||||
if b.all_day or (ini < b.time_to and fin > b.time_from):
|
if b.all_day or (ini < b.time_to and fin > b.time_from):
|
||||||
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
|
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
|
||||||
|
|
||||||
|
@api.constrains('date', 'time', 'doctor_id')
|
||||||
|
def _check_horario_medico(self):
|
||||||
|
"""Rechaza citas fuera del horario semanal del médico.
|
||||||
|
|
||||||
|
Sólo aplica a médicos CON horario configurado y sólo al crear/editar la
|
||||||
|
fecha, la hora o el médico: las citas históricas fuera de horario se
|
||||||
|
conservan intactas (cambios de estado u otros campos no las revalidan).
|
||||||
|
"""
|
||||||
|
for cita in self:
|
||||||
|
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
|
||||||
|
continue
|
||||||
|
dur = (cita.servicio_id.duration_min if cita.servicio_id else 30) or 30
|
||||||
|
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, dur)
|
||||||
|
if not ok:
|
||||||
|
raise ValidationError(_(mensaje))
|
||||||
|
|
||||||
def get_available_slots(self, date, servicio_id, doctor_id=None):
|
def get_available_slots(self, date, servicio_id, doctor_id=None):
|
||||||
"""Retorna slots disponibles para una fecha y servicio.
|
"""Retorna slots disponibles para una fecha y servicio.
|
||||||
Con doctor_id, además excluye los horarios bloqueados de ese médico
|
Con doctor_id, además excluye los horarios bloqueados de ese médico
|
||||||
(el usuario no debe poder elegir una hora bloqueada)."""
|
(el usuario no debe poder elegir una hora bloqueada) y respeta su
|
||||||
|
horario semanal cuando lo tiene configurado (sin horario configurado
|
||||||
|
el comportamiento es el de siempre: 8:00 a 19:00)."""
|
||||||
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
||||||
duration = servicio.duration_min if servicio else 30
|
duration = servicio.duration_min if servicio else 30
|
||||||
|
|
||||||
@@ -281,6 +299,16 @@ class SkeenCita(models.Model):
|
|||||||
start_time = 8.0 # 8:00 AM
|
start_time = 8.0 # 8:00 AM
|
||||||
end_time = 19.0 # 7:00 PM
|
end_time = 19.0 # 7:00 PM
|
||||||
|
|
||||||
|
# Horario del médico (si lo tiene configurado manda sobre el de la clínica)
|
||||||
|
if doctor_id:
|
||||||
|
doctor = self.env['hr.employee'].sudo().browse(int(doctor_id))
|
||||||
|
if doctor.exists() and doctor.horario_ids:
|
||||||
|
linea = doctor._horario_del_dia(date)
|
||||||
|
if not linea:
|
||||||
|
return [] # día no laborable para el médico
|
||||||
|
start_time = max(start_time, linea.hour_from)
|
||||||
|
end_time = min(end_time, linea.hour_to)
|
||||||
|
|
||||||
# Generar slots
|
# Generar slots
|
||||||
slots = []
|
slots = []
|
||||||
current = start_time
|
current = start_time
|
||||||
|
|||||||
@@ -1,6 +1,59 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from odoo import models, fields
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']
|
||||||
|
DIAS_ABREV = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||||
|
|
||||||
|
|
||||||
|
def _hora_str(h):
|
||||||
|
"""9.5 -> '9:30'"""
|
||||||
|
h = float(h or 0.0)
|
||||||
|
return '%d:%02d' % (int(h), int(round((h - int(h)) * 60)))
|
||||||
|
|
||||||
|
|
||||||
|
class HrEmployeeHorario(models.Model):
|
||||||
|
"""Línea de horario semanal del médico.
|
||||||
|
|
||||||
|
Un registro por día que el médico atiende. Un empleado SIN registros se
|
||||||
|
considera sin horario configurado (la agenda se comporta como siempre).
|
||||||
|
"""
|
||||||
|
_name = 'hr.employee.horario'
|
||||||
|
_description = 'Horario semanal del médico'
|
||||||
|
_order = 'day_of_week'
|
||||||
|
|
||||||
|
employee_id = fields.Many2one('hr.employee', string='Médico', required=True,
|
||||||
|
ondelete='cascade', index=True)
|
||||||
|
day_of_week = fields.Selection([
|
||||||
|
('0', 'Lunes'),
|
||||||
|
('1', 'Martes'),
|
||||||
|
('2', 'Miércoles'),
|
||||||
|
('3', 'Jueves'),
|
||||||
|
('4', 'Viernes'),
|
||||||
|
('5', 'Sábado'),
|
||||||
|
('6', 'Domingo'),
|
||||||
|
], string='Día', required=True)
|
||||||
|
hour_from = fields.Float(string='Hora inicio', required=True, default=9.0)
|
||||||
|
hour_to = fields.Float(string='Hora fin', required=True, default=14.0)
|
||||||
|
|
||||||
|
day_label = fields.Char(string='Día (texto)', compute='_compute_day_label')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_employee_day', 'unique(employee_id, day_of_week)',
|
||||||
|
'Ya hay un horario registrado para ese día.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('day_of_week')
|
||||||
|
def _compute_day_label(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.day_label = DIAS[int(rec.day_of_week or 0)]
|
||||||
|
|
||||||
|
@api.constrains('hour_from', 'hour_to')
|
||||||
|
def _check_horas(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.hour_to <= rec.hour_from:
|
||||||
|
raise ValidationError('La hora fin debe ser mayor a la hora inicio.')
|
||||||
|
|
||||||
|
|
||||||
class HrEmployee(models.Model):
|
class HrEmployee(models.Model):
|
||||||
@@ -18,3 +71,90 @@ class HrEmployee(models.Model):
|
|||||||
default='doctor',
|
default='doctor',
|
||||||
help='Tipo de personal de la agenda: Doctor o Cosmetóloga.',
|
help='Tipo de personal de la agenda: Doctor o Cosmetóloga.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
monthly_goal = fields.Float(
|
||||||
|
string='Meta mensual',
|
||||||
|
default=0.0,
|
||||||
|
help='Objetivo de venta del mes (m.n.) para el tablero del médico.',
|
||||||
|
)
|
||||||
|
|
||||||
|
horario_ids = fields.One2many(
|
||||||
|
'hr.employee.horario', 'employee_id', string='Horario semanal',
|
||||||
|
help='Días y horas que atiende. Si está vacío, la agenda no restringe horarios.',
|
||||||
|
)
|
||||||
|
tiene_horario = fields.Boolean(
|
||||||
|
string='Con horario configurado', compute='_compute_tiene_horario',
|
||||||
|
)
|
||||||
|
horario_resumen = fields.Char(
|
||||||
|
string='Horario', compute='_compute_horario_resumen',
|
||||||
|
help='Resumen legible (p. ej. "Lun-Vie 9:00-14:00").',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('horario_ids')
|
||||||
|
def _compute_tiene_horario(self):
|
||||||
|
for emp in self:
|
||||||
|
emp.tiene_horario = bool(emp.horario_ids)
|
||||||
|
|
||||||
|
@api.depends('horario_ids.day_of_week', 'horario_ids.hour_from', 'horario_ids.hour_to')
|
||||||
|
def _compute_horario_resumen(self):
|
||||||
|
for emp in self:
|
||||||
|
emp.horario_resumen = emp._horario_resumen()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Consultas de horario
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _horario_del_dia(self, fecha):
|
||||||
|
"""Línea de horario del día de la semana de `fecha` (vacío si no aplica)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not fecha:
|
||||||
|
return self.env['hr.employee.horario']
|
||||||
|
return self.horario_ids.filtered(lambda h: int(h.day_of_week) == fecha.weekday())[:1]
|
||||||
|
|
||||||
|
def _horario_resumen(self):
|
||||||
|
"""Resumen legible agrupando días consecutivos con el mismo horario.
|
||||||
|
|
||||||
|
Ej.: "Lun-Vie 9:00-14:00" o "Lun, Mié 9:00-14:00; Sáb 9:00-13:00".
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.horario_ids:
|
||||||
|
return ''
|
||||||
|
por_hora = {}
|
||||||
|
for h in self.horario_ids:
|
||||||
|
clave = (_hora_str(h.hour_from), _hora_str(h.hour_to))
|
||||||
|
por_hora.setdefault(clave, []).append(int(h.day_of_week))
|
||||||
|
partes = []
|
||||||
|
for (desde, hasta), dias in por_hora.items():
|
||||||
|
dias = sorted(dias)
|
||||||
|
tramos = []
|
||||||
|
inicio = previo = dias[0]
|
||||||
|
for d in dias[1:]:
|
||||||
|
if d == previo + 1:
|
||||||
|
previo = d
|
||||||
|
continue
|
||||||
|
tramos.append((inicio, previo))
|
||||||
|
inicio = previo = d
|
||||||
|
tramos.append((inicio, previo))
|
||||||
|
etiquetas = [
|
||||||
|
DIAS_ABREV[a] if a == b else '%s-%s' % (DIAS_ABREV[a], DIAS_ABREV[b])
|
||||||
|
for a, b in tramos
|
||||||
|
]
|
||||||
|
partes.append('%s %s-%s' % (', '.join(etiquetas), desde, hasta))
|
||||||
|
return '; '.join(partes)
|
||||||
|
|
||||||
|
def _valida_cita_en_horario(self, fecha, hora_ini, duracion_min):
|
||||||
|
"""(ok, mensaje) para una cita en `fecha` a las `hora_ini` (float de horas).
|
||||||
|
|
||||||
|
Si el médico no tiene horario configurado devuelve (True, '').
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.horario_ids or not fecha:
|
||||||
|
return True, ''
|
||||||
|
linea = self._horario_del_dia(fecha)
|
||||||
|
resumen = self._horario_resumen() or 'sin definir'
|
||||||
|
if not linea:
|
||||||
|
return False, 'El doctor no atiende el %s (su horario: %s)' % (
|
||||||
|
DIAS[fecha.weekday()].lower(), resumen)
|
||||||
|
dur = (duracion_min or 30) / 60.0
|
||||||
|
if hora_ini < linea.hour_from - 1e-6 or hora_ini + dur > linea.hour_to + 1e-6:
|
||||||
|
return False, 'El doctor no atiende a esa hora (su horario: %s)' % resumen
|
||||||
|
return True, ''
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ access_skeen_patient_adjunto_user,Acceso a Adjuntos de Paciente SKEEN,model_skee
|
|||||||
access_skeen_receta_user,Acceso a Recetas SKEEN,model_skeen_receta,base.group_user,1,1,1,1
|
access_skeen_receta_user,Acceso a Recetas SKEEN,model_skeen_receta,base.group_user,1,1,1,1
|
||||||
access_skeen_diagnostico_user,Acceso a Diagnósticos SKEEN,model_skeen_diagnostico,base.group_user,1,1,1,1
|
access_skeen_diagnostico_user,Acceso a Diagnósticos SKEEN,model_skeen_diagnostico,base.group_user,1,1,1,1
|
||||||
access_skeen_procedimiento_user,Acceso a Procedimientos SKEEN,model_skeen_procedimiento,base.group_user,1,1,1,1
|
access_skeen_procedimiento_user,Acceso a Procedimientos SKEEN,model_skeen_procedimiento,base.group_user,1,1,1,1
|
||||||
|
access_hr_employee_horario_user,Acceso a Horario semanal del médico,model_hr_employee_horario,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
@@ -2206,16 +2206,38 @@ class SkeenFrontendController(http.Controller):
|
|||||||
page = max(int(kw.get('page', 1) or 1), 1)
|
page = max(int(kw.get('page', 1) or 1), 1)
|
||||||
page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200)
|
page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200)
|
||||||
Servicio = request.env['skeen.servicio'].sudo()
|
Servicio = request.env['skeen.servicio'].sudo()
|
||||||
total = Servicio.search_count(domain)
|
|
||||||
services = Servicio.search(domain, order='sequence asc, name asc',
|
# Orden: manual (sequence, default) o por más vendidos de los
|
||||||
limit=page_size, offset=(page - 1) * page_size)
|
# últimos 90 días. "best_sellers" ordena en memoria porque el
|
||||||
|
# ranking sale de las líneas de venta, no del catálogo.
|
||||||
|
sort = kw.get('sort') or 'manual'
|
||||||
|
ventas_90d = {}
|
||||||
|
if sort == 'best_sellers':
|
||||||
|
desde = (date.today() - timedelta(days=90)).strftime('%Y-%m-%d')
|
||||||
|
lineas = request.env['skeen.venta.line'].sudo().read_group(
|
||||||
|
[('service_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
|
||||||
|
('venta_id.date', '>=', desde)],
|
||||||
|
['service_id', 'quantity:sum'], ['service_id'])
|
||||||
|
ventas_90d = {g['service_id'][0]: (g.get('quantity') or 0.0) for g in lineas}
|
||||||
|
|
||||||
|
if sort == 'best_sellers':
|
||||||
|
todos = Servicio.search(domain, order='sequence asc, name asc')
|
||||||
|
todos = todos.sorted(
|
||||||
|
key=lambda s: (-ventas_90d.get(s.id, 0.0), s.sequence or 0, s.name or ''))
|
||||||
|
total = len(todos)
|
||||||
|
services = todos[(page - 1) * page_size: page * page_size]
|
||||||
|
else:
|
||||||
|
total = Servicio.search_count(domain)
|
||||||
|
services = Servicio.search(domain, order='sequence asc, name asc',
|
||||||
|
limit=page_size, offset=(page - 1) * page_size)
|
||||||
return json_response({
|
return json_response({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'services': [self._service_to_dict(s) for s in services],
|
'services': [self._service_to_dict(s, ventas_90d) for s in services],
|
||||||
'total': total,
|
'total': total,
|
||||||
'page': page,
|
'page': page,
|
||||||
'page_size': page_size,
|
'page_size': page_size,
|
||||||
'total_pages': (total + page_size - 1) // page_size,
|
'total_pages': (total + page_size - 1) // page_size,
|
||||||
|
'sort': sort,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
@@ -2262,6 +2284,47 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('admin')
|
||||||
|
def reorder_services(self, **kw):
|
||||||
|
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
|
||||||
|
|
||||||
|
Los servicios de `ids` quedan, en ese mismo orden, ocupando la posición
|
||||||
|
del primero de ellos (así funcionan las flechas subir/bajar: se envía la
|
||||||
|
página ya reordenada). Con `to: 'front'` el bloque pasa al inicio del
|
||||||
|
catálogo (botón "Mostrar primero en POS"). Con la numeración
|
||||||
|
normalizada el orden manual es estable y el POS lo respeta al listar.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _parse_json_body()
|
||||||
|
raw = data.get('ids') or []
|
||||||
|
if not isinstance(raw, list) or not raw:
|
||||||
|
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
|
||||||
|
try:
|
||||||
|
ids = [int(i) for i in raw]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
|
||||||
|
Servicio = request.env['skeen.servicio'].sudo()
|
||||||
|
todos = Servicio.search([], order='sequence asc, name asc')
|
||||||
|
orden = [s.id for s in todos]
|
||||||
|
mover_set = set(ids)
|
||||||
|
mover = [i for i in ids if i in set(orden)]
|
||||||
|
if not mover:
|
||||||
|
return json_response({'status': 'error', 'message': 'Ningún servicio válido en la lista'}, 404)
|
||||||
|
resto = [i for i in orden if i not in mover_set]
|
||||||
|
if (data.get('to') or '').lower() == 'front':
|
||||||
|
nuevo = mover + resto
|
||||||
|
else:
|
||||||
|
primera = orden.index(mover[0])
|
||||||
|
pos = len([i for i in orden[:primera] if i not in mover_set])
|
||||||
|
nuevo = resto[:pos] + mover + resto[pos:]
|
||||||
|
for idx, sid in enumerate(nuevo):
|
||||||
|
Servicio.browse(sid).write({'sequence': (idx + 1) * 10})
|
||||||
|
return json_response({'status': 'success', 'total': len(nuevo), 'ids': nuevo[:len(ids)]})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
def update_service(self, service_id, **kw):
|
def update_service(self, service_id, **kw):
|
||||||
@@ -2270,9 +2333,14 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if not service.exists():
|
if not service.exists():
|
||||||
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active']
|
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence']
|
||||||
vals = {k: data[k] for k in allowed if k in data}
|
vals = {k: data[k] for k in allowed if k in data}
|
||||||
Servicio = request.env['skeen.servicio'].sudo()
|
Servicio = request.env['skeen.servicio'].sudo()
|
||||||
|
if 'sequence' in vals:
|
||||||
|
try:
|
||||||
|
vals['sequence'] = int(vals['sequence'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400)
|
||||||
if vals.get('name'):
|
if vals.get('name'):
|
||||||
dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1)
|
dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1)
|
||||||
if dup:
|
if dup:
|
||||||
@@ -2286,7 +2354,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
def _service_to_dict(self, s):
|
def _service_to_dict(self, s, ventas_90d=None):
|
||||||
return {
|
return {
|
||||||
'id': s.id,
|
'id': s.id,
|
||||||
'code': s.code,
|
'code': s.code,
|
||||||
@@ -2301,6 +2369,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'color': s.color,
|
'color': s.color,
|
||||||
'service_group': s.service_group or '',
|
'service_group': s.service_group or '',
|
||||||
'is_favorite': s.is_favorite,
|
'is_favorite': s.is_favorite,
|
||||||
|
'sequence': s.sequence or 0,
|
||||||
|
'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None,
|
||||||
'genera_monedero': s.genera_monedero,
|
'genera_monedero': s.genera_monedero,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2339,6 +2409,15 @@ class SkeenFrontendController(http.Controller):
|
|||||||
# ---- Ventas de los últimos 30 días por médico ----
|
# ---- Ventas de los últimos 30 días por médico ----
|
||||||
since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
|
since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# ---- Ventas del mes en curso (para la meta mensual) ----
|
||||||
|
mes_ini = date.today().replace(day=1).strftime('%Y-%m-%d')
|
||||||
|
mes_total = {}
|
||||||
|
for g in request.env['skeen.venta.line'].sudo().read_group(
|
||||||
|
[('prescribed_by_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
|
||||||
|
('venta_id.date', '>=', mes_ini)],
|
||||||
|
['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']):
|
||||||
|
mes_total[g['prescribed_by_id'][0]] = round(g['subtotal'] or 0.0, 2)
|
||||||
|
|
||||||
# Recomendaciones: pacientes captados en 30d asignados al médico
|
# Recomendaciones: pacientes captados en 30d asignados al médico
|
||||||
# con "recomendado por" capturado (misma métrica visible del legacy)
|
# con "recomendado por" capturado (misma métrica visible del legacy)
|
||||||
recomendaciones = {}
|
recomendaciones = {}
|
||||||
@@ -2391,6 +2470,15 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'work_email': d.work_email or '',
|
'work_email': d.work_email or '',
|
||||||
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
|
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
|
||||||
'staff_type': d.staff_type or 'doctor',
|
'staff_type': d.staff_type or 'doctor',
|
||||||
|
'monthly_goal': 0.0 if hide_money else (d.monthly_goal or 0.0),
|
||||||
|
'month_total': 0.0 if hide_money else mes_total.get(d.id, 0.0),
|
||||||
|
'horario': [{
|
||||||
|
'day_of_week': h.day_of_week,
|
||||||
|
'hour_from': h.hour_from,
|
||||||
|
'hour_to': h.hour_to,
|
||||||
|
} for h in d.horario_ids],
|
||||||
|
'tiene_horario': bool(d.horario_ids),
|
||||||
|
'horario_resumen': d._horario_resumen(),
|
||||||
'patient_count': counts.get(d.id, 0),
|
'patient_count': counts.get(d.id, 0),
|
||||||
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
||||||
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
|
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
|
||||||
@@ -2413,6 +2501,14 @@ class SkeenFrontendController(http.Controller):
|
|||||||
vals = {}
|
vals = {}
|
||||||
if 'commission_pct' in data:
|
if 'commission_pct' in data:
|
||||||
vals['commission_pct'] = float(data.get('commission_pct') or 0)
|
vals['commission_pct'] = float(data.get('commission_pct') or 0)
|
||||||
|
if 'monthly_goal' in data:
|
||||||
|
try:
|
||||||
|
meta = float(data.get('monthly_goal') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error', 'message': 'La meta mensual debe ser numérica'}, 400)
|
||||||
|
if meta < 0:
|
||||||
|
return json_response({'status': 'error', 'message': 'La meta mensual no puede ser negativa'}, 400)
|
||||||
|
vals['monthly_goal'] = meta
|
||||||
if 'staff_type' in data:
|
if 'staff_type' in data:
|
||||||
if data['staff_type'] not in ('doctor', 'cosmetologa'):
|
if data['staff_type'] not in ('doctor', 'cosmetologa'):
|
||||||
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
|
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
|
||||||
@@ -2425,12 +2521,87 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
|
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
|
||||||
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
|
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
|
||||||
'commission_pct': doctor.commission_pct or 0.0,
|
'commission_pct': doctor.commission_pct or 0.0,
|
||||||
|
'monthly_goal': doctor.monthly_goal or 0.0,
|
||||||
'staff_type': doctor.staff_type or 'doctor',
|
'staff_type': doctor.staff_type or 'doctor',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def get_doctor_horario(self, doctor_id, **kw):
|
||||||
|
"""Horario semanal del médico (líneas por día que atiende)."""
|
||||||
|
try:
|
||||||
|
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
|
||||||
|
if not doctor.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'doctor_id': doctor.id,
|
||||||
|
'tiene_horario': bool(doctor.horario_ids),
|
||||||
|
'horario_resumen': doctor._horario_resumen(),
|
||||||
|
'horario': [{
|
||||||
|
'day_of_week': h.day_of_week,
|
||||||
|
'hour_from': h.hour_from,
|
||||||
|
'hour_to': h.hour_to,
|
||||||
|
} for h in doctor.horario_ids],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('admin')
|
||||||
|
def set_doctor_horario(self, doctor_id, **kw):
|
||||||
|
"""Reemplaza el horario semanal del médico.
|
||||||
|
|
||||||
|
Body: {"horario": [{"day_of_week": "0", "hour_from": 9, "hour_to": 14}, ...]}
|
||||||
|
Una lista vacía deja al médico SIN horario configurado (la agenda no
|
||||||
|
restringe). No toca las citas ya agendadas.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
|
||||||
|
if not doctor.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
|
||||||
|
data = _parse_json_body()
|
||||||
|
lineas = data.get('horario')
|
||||||
|
if not isinstance(lineas, list):
|
||||||
|
return json_response({'status': 'error', 'message': 'Falta la lista de horario'}, 400)
|
||||||
|
vals = []
|
||||||
|
vistos = set()
|
||||||
|
for l in lineas:
|
||||||
|
dia = str(l.get('day_of_week', '')).strip()
|
||||||
|
if dia not in [str(i) for i in range(7)]:
|
||||||
|
return json_response({'status': 'error', 'message': f'Día inválido: {dia}'}, 400)
|
||||||
|
if dia in vistos:
|
||||||
|
return json_response({'status': 'error', 'message': 'Hay días repetidos en el horario'}, 400)
|
||||||
|
try:
|
||||||
|
hf = float(l.get('hour_from'))
|
||||||
|
ht = float(l.get('hour_to'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
|
||||||
|
if not (0 <= hf < ht <= 24):
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'La hora fin debe ser mayor a la hora inicio y estar entre 0:00 y 24:00',
|
||||||
|
}, 400)
|
||||||
|
vistos.add(dia)
|
||||||
|
vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht}))
|
||||||
|
doctor.write({'horario_ids': [(5, 0, 0)] + vals})
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'horario': [{
|
||||||
|
'day_of_week': h.day_of_week,
|
||||||
|
'hour_from': h.hour_from,
|
||||||
|
'hour_to': h.hour_to,
|
||||||
|
} for h in doctor.horario_ids],
|
||||||
|
'tiene_horario': bool(doctor.horario_ids),
|
||||||
|
'horario_resumen': doctor._horario_resumen(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/commission', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/commission', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
def set_doctor_commission(self, doctor_id, **kw):
|
def set_doctor_commission(self, doctor_id, **kw):
|
||||||
@@ -2640,6 +2811,18 @@ class SkeenFrontendController(http.Controller):
|
|||||||
else:
|
else:
|
||||||
pct, pct_source = global_pct, 'global'
|
pct, pct_source = global_pct, 'global'
|
||||||
|
|
||||||
|
# Meta mensual: progreso del MES EN CURSO (independiente del rango
|
||||||
|
# seleccionado), para la barra meta vs real de "Mis ventas".
|
||||||
|
mes_ini = today.replace(day=1)
|
||||||
|
mes_rows = Lineas.read_group([
|
||||||
|
('prescribed_by_id', '=', emp.id),
|
||||||
|
('venta_id.state', '!=', 'cancelled'),
|
||||||
|
('venta_id.date', '>=', mes_ini.strftime('%Y-%m-%d')),
|
||||||
|
('venta_id.date', '<=', today.strftime('%Y-%m-%d')),
|
||||||
|
], ['subtotal:sum'], [])
|
||||||
|
mes_real = round((mes_rows[0]['subtotal'] or 0.0) if mes_rows else 0.0, 2)
|
||||||
|
meta = emp.monthly_goal or 0.0
|
||||||
|
|
||||||
return json_response({
|
return json_response({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'doctor': {'id': emp.id, 'name': emp.name},
|
'doctor': {'id': emp.id, 'name': emp.name},
|
||||||
@@ -2652,6 +2835,13 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'total_usd': round(total_mxn / rate, 2) if rate else None,
|
'total_usd': round(total_mxn / rate, 2) if rate else None,
|
||||||
'total_card': round(sum(card_by_venta.values()), 2),
|
'total_card': round(sum(card_by_venta.values()), 2),
|
||||||
},
|
},
|
||||||
|
'monthly_goal': {
|
||||||
|
'goal': meta,
|
||||||
|
'real': mes_real,
|
||||||
|
'pct': round(mes_real / meta * 100.0, 1) if meta else None,
|
||||||
|
'from': mes_ini.strftime('%Y-%m-%d'),
|
||||||
|
'to': today.strftime('%Y-%m-%d'),
|
||||||
|
},
|
||||||
'sales': sales,
|
'sales': sales,
|
||||||
'daily_breakdown': daily_breakdown,
|
'daily_breakdown': daily_breakdown,
|
||||||
'commission_pct': pct,
|
'commission_pct': pct,
|
||||||
|
|||||||
Reference in New Issue
Block a user