From cd945df6a58fa3a527ed34eead1af3c3af3f3d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Thu, 17 Sep 2026 21:28:43 +0000 Subject: [PATCH] Oleada UX 5: Servicios (duplicar, favoritos y orden para POS, mas vendidos) y Medicos (semana tipo con validacion en agenda, meta mensual con progreso) --- frontend/src/pages/Agenda.tsx | 53 +++- frontend/src/pages/Medicos.tsx | 262 +++++++++++++++++- frontend/src/pages/MisVentas.tsx | 44 ++- frontend/src/pages/Pos.tsx | 35 ++- frontend/src/pages/Servicios.tsx | 123 +++++++- frontend/src/services/odoo.ts | 34 ++- odoo-addons/skeen_citas/models/cita.py | 30 +- .../skeen_pacientes/models/hr_employee.py | 142 +++++++++- .../security/ir.model.access.csv | 1 + .../skeen_whatsapp/controllers/frontend.py | 202 +++++++++++++- 10 files changed, 885 insertions(+), 41 deletions(-) diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index c4e2b14..d254625 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -392,6 +392,21 @@ const Agenda: FC = () => { (b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from))) : 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) const DRAGGABLE_STATES = ['pending', 'confirmed']; const handleDrop = async (doctorId: number | null, slot: number) => { @@ -1629,6 +1644,7 @@ const Agenda: FC = () => { {gridColumns.map((col) => { const cell = gridCellAppointments(col.doctorId, slot); const bloqueo = bloqueoEnCelda(col.doctorId, slot); + const fuera = fueraDeHorario(col.doctorId, slot); const cellKey = `${col.key}-${slot}`; return ( { onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }} onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))} 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 ? (
{
) : cell.length === 0 ? (
clickCeldaLibre(e, date, slot, col.doctorId)} - className={`cursor-pointer rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${ - quickFilter === 'libres' - ? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold' - : 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg' + onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }} + className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${ + fuera + ? 'border-theme-border bg-theme-bg text-theme-muted cursor-not-allowed' + : 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'}
) : ( cell.map((apt) => ( @@ -1739,6 +1758,7 @@ const Agenda: FC = () => { {gridSlots.map((slot) => { const cell = gridCellAppointments(col.doctorId, slot); const bloqueo = bloqueoEnCelda(col.doctorId, slot); + const fuera = fueraDeHorario(col.doctorId, slot); const cellKey = `${col.key}-${slot}`; const isNowSlot = nowSlot !== null && slot === nowSlot; return ( @@ -1747,7 +1767,8 @@ const Agenda: FC = () => { onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }} onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))} 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 ? (
{
) : cell.length === 0 ? (
clickCeldaLibre(e, date, slot, col.doctorId)} - className={`cursor-pointer rounded border py-1.5 transition-colors ${ - quickFilter === 'libres' - ? 'bg-brand-mint border-emerald-700' - : 'border-dashed border-theme-border hover:bg-theme-accent-bg' + onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }} + className={`rounded border py-1.5 transition-colors ${ + fuera + ? 'cursor-not-allowed border-theme-border bg-theme-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) => ( diff --git a/frontend/src/pages/Medicos.tsx b/frontend/src/pages/Medicos.tsx index 507dfca..22a72b3 100644 --- a/frontend/src/pages/Medicos.tsx +++ b/frontend/src/pages/Medicos.tsx @@ -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 => { + const out: Record = {}; + 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 ( +
+
+ Meta del mes + {goal > 0 ? `${fmtNum(pct, 0)}%` : 'sin meta'} +
+
+
+
+

+ {fmtMoney(real)}{goal > 0 ? ` de ${fmtMoney(goal)}` : ''} +

+
+ ); +}; 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(null); - const [modalTab, setModalTab] = useState<'info' | 'historial'>('info'); + const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info'); const [hist, setHist] = useState(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>(diaDraftVacio7()); + const [savingHorario, setSavingHorario] = useState(false); + + function diaDraftVacio7(): Record { + const out: Record = {}; + 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) => + setDias((prev) => ({ ...prev, [day]: { ...prev[day], ...patch } })); + + const copiarLunes = () => { + const lunes = dias['0']; + setDias((prev) => { + const out: Record = {}; + 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 = () => { Email % Comisión Últimos 30 días + Meta del mes + Horario @@ -253,6 +384,18 @@ const Medicos: FC = () => { + + + + + {d.tiene_horario ? ( + + {d.horario_resumen} + + ) : ( + Sin horario + )} + ))} @@ -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: }, + { label: 'Horario', value: d.tiene_horario ? d.horario_resumen : 'Sin horario' }, { label: 'Últimos 30 días', value: }, ]} actions={ @@ -325,7 +470,7 @@ const Medicos: FC = () => { {/* Tabs */}
- {([{ 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) => (
+ +
+

Meta mensual

+
+
+ setGoalDraft(e.target.value)} + /> +
+ +
+ +
+
+
+ + ) : modalTab === 'horario' ? ( +
+
+
+

Semana tipo (horario base)

+

+ 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. +

+
+ +
+ +
+ {DIAS.map((nombre, i) => { + const key = String(i); + const dia = dias[key] || diaDraftVacio(); + const activo = dia.works; + return ( +
+ +
+ setDia(key, { from: e.target.value })} + className="max-w-[130px]" + /> + a + setDia(key, { to: e.target.value })} + className="max-w-[130px]" + /> +
+ {!activo && No atiende} +
+ ); + })} +
+ +
+

+ {selected.tiene_horario + ? `Horario actual: ${selected.horario_resumen}` + : 'Sin horario configurado: la agenda funciona como siempre (8:00 a 19:00).'} +

+
+ + +
+
) : (
diff --git a/frontend/src/pages/MisVentas.tsx b/frontend/src/pages/MisVentas.tsx index 99d96b9..43d98b6 100644 --- a/frontend/src/pages/MisVentas.tsx +++ b/frontend/src/pages/MisVentas.tsx @@ -1,6 +1,6 @@ import type { FC } 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 { Card, @@ -157,6 +157,48 @@ const MisVentas: FC = () => {
+ {/* Progreso de la meta mensual */} + {data.monthly_goal && ( + + +
+

+ Meta del mes +

+

+ {data.monthly_goal.from} a {data.monthly_goal.to} +

+
+ {data.monthly_goal.goal > 0 ? ( + <> +
+
= data.monthly_goal.goal ? 'bg-emerald-600' : 'bg-theme-accent' + }`} + style={{ width: `${Math.min((data.monthly_goal.real / data.monthly_goal.goal) * 100, 100)}%` }} + /> +
+

+ {mxn(data.monthly_goal.real)} + {' '}de {mxn(data.monthly_goal.goal)} ·{' '} + = data.monthly_goal.goal ? 'text-emerald-700 font-semibold' : ''}> + {data.monthly_goal.pct ?? 0}% + + {data.monthly_goal.real >= data.monthly_goal.goal + ? ' · meta cumplida' + : ` · faltan ${mxn(Math.max(data.monthly_goal.goal - data.monthly_goal.real, 0))}`} +

+ + ) : ( +

+ Aún no tienes meta asignada. Pide a un administrador que la configure en el módulo Médicos. +

+ )} + + + )} + {/* Gráfica de ventas por día */} diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index 34d3a64..8edb15e 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package, - RotateCcw, Keyboard, Lock, + RotateCcw, Keyboard, Lock, TrendingUp, } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; @@ -57,6 +57,7 @@ const Pos: FC = () => { const [search, setSearch] = useState(''); const [categoria, setCategoria] = useState(''); const [soloFavoritos, setSoloFavoritos] = useState(false); + const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual'); const [invItems, setInvItems] = useState([]); // Ticket @@ -106,10 +107,17 @@ const Pos: FC = () => { const [ticketMovil, setTicketMovil] = useState(false); // ---- 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 () => { try { 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); } catch (err) { toast.error('Error al cargar servicios'); @@ -117,7 +125,7 @@ const Pos: FC = () => { } finally { setCatalogLoading(false); } - }, [search, categoria]); + }, [search, categoria, ordenServicios]); const loadItems = useCallback(async () => { try { @@ -839,6 +847,27 @@ const Pos: FC = () => { {c} ))} + + +
)} diff --git a/frontend/src/pages/Servicios.tsx b/frontend/src/pages/Servicios.tsx index e78dfe5..4e83d52 100644 --- a/frontend/src/pages/Servicios.tsx +++ b/frontend/src/pages/Servicios.tsx @@ -1,6 +1,6 @@ import type { FC } 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 { Card, @@ -77,6 +77,8 @@ const Servicios: FC = () => { const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); + const [duplicando, setDuplicando] = useState(false); + const [reordenando, setReordenando] = useState(false); const [form, setForm] = useState(emptyForm); const load = useCallback(async () => { @@ -136,12 +138,14 @@ const Servicios: FC = () => { const openCreate = () => { setEditing(null); + setDuplicando(false); setForm(emptyForm); setModalOpen(true); }; const openEdit = (s: Service) => { setEditing(s); + setDuplicando(false); setForm({ name: s.name, code: s.code || '', @@ -158,6 +162,59 @@ const Servicios: FC = () => { 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 () => { if (!editing) 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); setEditing(null); + setDuplicando(false); await load(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; @@ -273,7 +331,7 @@ const Servicios: FC = () => { ) : ( <>
- {services.map((s) => ( + {services.map((s, idx) => (
@@ -332,8 +390,44 @@ const Servicios: FC = () => {

Precio sesión

{formatCurrency(s.price)}

- + + + +
+ +
+ Orden POS #{(page - 1) * pageSize + idx + 1} +
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && ( @@ -373,8 +467,8 @@ const Servicios: FC = () => { {/* Modal crear/editar */} { setModalOpen(false); setEditing(null); }} - title={editing ? `Editar: ${editing.name}` : 'Nuevo servicio'} + onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }} + title={editing ? `Editar: ${editing.name}` : duplicando ? 'Duplicar servicio' : 'Nuevo servicio'} maxWidth="lg" footer={ <> @@ -383,15 +477,28 @@ const Servicios: FC = () => { Desactivar )} - - + {editing && ( + + )} + + } >
+ {duplicando && ( +

+ Copia precargada. Ajusta el nombre y el precio (el código lleva el sufijo -COPIA). +

+ )} e.target.select()} onChange={(e) => setForm({ ...form, name: e.target.value })} />
diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 2e46da3..74131bb 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -255,6 +255,8 @@ export interface Service { color: string; service_group: string; is_favorite: boolean; + sequence?: number; + sold_90d?: number | null; genera_monedero?: boolean; } @@ -266,6 +268,11 @@ export interface Doctor { work_email: string; commission_pct: number; 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; recomendaciones_30d?: number; ventas_30d?: number; @@ -454,6 +461,13 @@ export interface MySalesResponse { commission_pct: number; commission_source?: string; 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 { @@ -1352,7 +1366,7 @@ export const odooApi = { }, // 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 }); return data; }, @@ -1372,6 +1386,11 @@ export const odooApi = { 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): Promise<{ status: string; service: Service }> { const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service); return data; @@ -1383,11 +1402,22 @@ export const odooApi = { 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); 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 } }> { const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/commission`, { commission_pct }); return data; diff --git a/odoo-addons/skeen_citas/models/cita.py b/odoo-addons/skeen_citas/models/cita.py index c02ca5b..fc4acde 100644 --- a/odoo-addons/skeen_citas/models/cita.py +++ b/odoo-addons/skeen_citas/models/cita.py @@ -265,10 +265,28 @@ class SkeenCita(models.Model): 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')) + @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): """Retorna slots disponibles para una fecha y servicio. 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) duration = servicio.duration_min if servicio else 30 @@ -281,6 +299,16 @@ class SkeenCita(models.Model): start_time = 8.0 # 8:00 AM 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 slots = [] current = start_time diff --git a/odoo-addons/skeen_pacientes/models/hr_employee.py b/odoo-addons/skeen_pacientes/models/hr_employee.py index 65476ee..2244f2e 100644 --- a/odoo-addons/skeen_pacientes/models/hr_employee.py +++ b/odoo-addons/skeen_pacientes/models/hr_employee.py @@ -1,6 +1,59 @@ # -*- 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): @@ -18,3 +71,90 @@ class HrEmployee(models.Model): default='doctor', 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, '' diff --git a/odoo-addons/skeen_pacientes/security/ir.model.access.csv b/odoo-addons/skeen_pacientes/security/ir.model.access.csv index c8eab3f..ece414f 100644 --- a/odoo-addons/skeen_pacientes/security/ir.model.access.csv +++ b/odoo-addons/skeen_pacientes/security/ir.model.access.csv @@ -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_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_hr_employee_horario_user,Acceso a Horario semanal del médico,model_hr_employee_horario,base.group_user,1,1,1,1 diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 90ce38f..a38a9d7 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -2206,16 +2206,38 @@ class SkeenFrontendController(http.Controller): page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200) Servicio = request.env['skeen.servicio'].sudo() - total = Servicio.search_count(domain) - services = Servicio.search(domain, order='sequence asc, name asc', - limit=page_size, offset=(page - 1) * page_size) + + # Orden: manual (sequence, default) o por más vendidos de los + # ú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({ '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, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, + 'sort': sort, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @@ -2262,6 +2284,47 @@ class SkeenFrontendController(http.Controller): except Exception as e: 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/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('admin') def update_service(self, service_id, **kw): @@ -2270,9 +2333,14 @@ class SkeenFrontendController(http.Controller): if not service.exists(): return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404) 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} 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'): dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1) if dup: @@ -2286,7 +2354,7 @@ class SkeenFrontendController(http.Controller): except Exception as e: 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 { 'id': s.id, 'code': s.code, @@ -2301,6 +2369,8 @@ class SkeenFrontendController(http.Controller): 'color': s.color, 'service_group': s.service_group or '', '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, } @@ -2339,6 +2409,15 @@ class SkeenFrontendController(http.Controller): # ---- Ventas de los últimos 30 días por médico ---- 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 # con "recomendado por" capturado (misma métrica visible del legacy) recomendaciones = {} @@ -2391,6 +2470,15 @@ class SkeenFrontendController(http.Controller): 'work_email': d.work_email or '', 'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0), '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), 'recomendaciones_30d': recomendaciones.get(d.id, 0), 'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())), @@ -2413,6 +2501,14 @@ class SkeenFrontendController(http.Controller): vals = {} if 'commission_pct' in data: 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 data['staff_type'] not in ('doctor', 'cosmetologa'): 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 '', 'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '', 'commission_pct': doctor.commission_pct or 0.0, + 'monthly_goal': doctor.monthly_goal or 0.0, 'staff_type': doctor.staff_type or 'doctor', }, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) + @http.route('/skeen/frontend/v1/doctors//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//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//commission', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') def set_doctor_commission(self, doctor_id, **kw): @@ -2640,6 +2811,18 @@ class SkeenFrontendController(http.Controller): else: 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({ 'status': 'success', '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_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, 'daily_breakdown': daily_breakdown, 'commission_pct': pct,