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:
2026-09-17 21:28:43 +00:00
parent 9ee3343e22
commit cd945df6a5
10 changed files with 885 additions and 41 deletions

View File

@@ -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 (
<td
@@ -1636,7 +1652,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-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 ? (
<div
@@ -1649,15 +1666,17 @@ const Agenda: FC = () => {
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => 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'}
</div>
) : (
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 ? (
<div
@@ -1759,13 +1780,15 @@ const Agenda: FC = () => {
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => 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) => (

View File

@@ -1,9 +1,58 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign } from 'lucide-react';
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
import { odooApi, type Doctor, type DoctorHistory } from '../services/odoo';
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
const hhmm = (h: number) => {
const hh = Math.floor(h || 0);
const mm = Math.round(((h || 0) - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
};
const parseHM = (s: string) => {
const [h, m] = (s || '').split(':').map((v) => parseInt(v, 10));
return (Number.isNaN(h) ? 0 : h) + (Number.isNaN(m) ? 0 : m) / 60;
};
type DiaDraft = { works: boolean; from: string; to: string };
const diaDraftVacio = (): DiaDraft => ({ works: false, from: '09:00', to: '14:00' });
const draftDesdeHorario = (horario?: HorarioDia[]): Record<string, DiaDraft> => {
const out: Record<string, DiaDraft> = {};
for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio();
(horario || []).forEach((h) => {
out[h.day_of_week] = { works: true, from: hhmm(h.hour_from), to: hhmm(h.hour_to) };
});
return out;
};
/** Barra simple de progreso meta vs real del mes. */
const GoalBar: FC<{ goal: number; real: number }> = ({ goal, real }) => {
const pct = goal > 0 ? Math.min((real / goal) * 100, 100) : 0;
const cumplido = goal > 0 && real >= goal;
return (
<div className="w-full min-w-[150px]">
<div className="flex items-center justify-between text-[11px] text-theme-muted mb-1">
<span className="inline-flex items-center gap-1"><Target size={11} /> Meta del mes</span>
<span className="tabular-nums">{goal > 0 ? `${fmtNum(pct, 0)}%` : 'sin meta'}</span>
</div>
<div className="h-2 w-full rounded-full bg-theme-bg overflow-hidden">
<div
className={`h-full rounded-full transition-all ${cumplido ? 'bg-emerald-600' : 'bg-theme-accent'}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-[11px] text-theme-muted tabular-nums">
{fmtMoney(real)}{goal > 0 ? ` de ${fmtMoney(goal)}` : ''}
</p>
</div>
);
};
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
@@ -113,16 +162,30 @@ const Medicos: FC = () => {
// Modal de detalle del médico
const [selected, setSelected] = useState<Doctor | null>(null);
const [modalTab, setModalTab] = useState<'info' | 'historial'>('info');
const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info');
const [hist, setHist] = useState<DoctorHistory | null>(null);
const [histLoading, setHistLoading] = useState(false);
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
// Meta mensual (editable en el modal) y horario semanal
const [goalDraft, setGoalDraft] = useState('');
const [savingGoal, setSavingGoal] = useState(false);
const [dias, setDias] = useState<Record<string, DiaDraft>>(diaDraftVacio7());
const [savingHorario, setSavingHorario] = useState(false);
function diaDraftVacio7(): Record<string, DiaDraft> {
const out: Record<string, DiaDraft> = {};
for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio();
return out;
}
const openDoctor = (d: Doctor) => {
setSelected(d);
setModalTab('info');
setHistSection('citas');
setHist(null);
setGoalDraft(String(d.monthly_goal ?? 0));
setDias(draftDesdeHorario(d.horario));
setHistLoading(true);
odooApi.getDoctorHistory(d.id)
.then((res) => { if (res.status === 'success') setHist(res); })
@@ -130,6 +193,72 @@ const Medicos: FC = () => {
.finally(() => setHistLoading(false));
};
const saveGoal = async (d: Doctor) => {
const meta = parseFloat(goalDraft || '0');
if (Number.isNaN(meta) || meta < 0) {
toast.error('Meta inválida');
return;
}
setSavingGoal(true);
try {
await odooApi.updateDoctor(d.id, { monthly_goal: meta });
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, monthly_goal: meta } : e)));
setSelected((s) => (s && s.id === d.id ? { ...s, monthly_goal: meta } : s));
toast.success(`Meta de ${d.name}: ${fmtMoney(meta)}`);
} catch {
toast.error('No se pudo guardar la meta');
} finally {
setSavingGoal(false);
}
};
const setDia = (day: string, patch: Partial<DiaDraft>) =>
setDias((prev) => ({ ...prev, [day]: { ...prev[day], ...patch } }));
const copiarLunes = () => {
const lunes = dias['0'];
setDias((prev) => {
const out: Record<string, DiaDraft> = {};
for (let d = 1; d < 7; d++) out[String(d)] = { ...lunes };
return { ...prev, ...out };
});
toast.success('Horario del lunes copiado a todos los días');
};
const saveHorario = async (d: Doctor) => {
const lineas: HorarioDia[] = [];
for (let i = 0; i < 7; i++) {
const key = String(i);
const dia = dias[key];
if (!dia?.works) continue;
const hf = parseHM(dia.from);
const ht = parseHM(dia.to);
if (!(ht > hf)) {
toast.error(`${DIAS[i]}: la hora fin debe ser mayor a la hora inicio`);
return;
}
lineas.push({ day_of_week: key, hour_from: hf, hour_to: ht });
}
setSavingHorario(true);
try {
const res = await odooApi.setDoctorHorario(d.id, lineas);
setEmployees((prev) => prev.map((e) => (e.id === d.id
? { ...e, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen }
: e)));
setSelected((s) => (s && s.id === d.id
? { ...s, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen }
: s));
toast.success(res.tiene_horario
? `Horario guardado: ${res.horario_resumen}`
: 'Horario borrado: la agenda no restringirá horarios para este médico');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar el horario');
} finally {
setSavingHorario(false);
}
};
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
const savePct = async (d: Doctor) => {
@@ -193,6 +322,8 @@ const Medicos: FC = () => {
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase" title="Ventas con líneas recetadas por el médico, últimos 30 días">Últimos 30 días</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden xl:table-cell">Meta del mes</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden xl:table-cell">Horario</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -253,6 +384,18 @@ const Medicos: FC = () => {
<td className="p-3 align-top">
<StatBadges d={d} />
</td>
<td className="p-3 align-top hidden xl:table-cell">
<GoalBar goal={d.monthly_goal ?? 0} real={d.month_total ?? 0} />
</td>
<td className="p-3 align-top hidden xl:table-cell">
{d.tiene_horario ? (
<span className="text-xs text-theme-heading" title={d.horario_resumen}>
{d.horario_resumen}
</span>
) : (
<span className="text-xs text-theme-muted">Sin horario</span>
)}
</td>
</tr>
))}
</tbody>
@@ -274,6 +417,8 @@ const Medicos: FC = () => {
{ label: 'Teléfono', value: d.work_phone || '-' },
{ label: 'Email', value: d.work_email || '-' },
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
{ label: 'Meta del mes', value: <GoalBar goal={d.monthly_goal ?? 0} real={d.month_total ?? 0} /> },
{ label: 'Horario', value: d.tiene_horario ? d.horario_resumen : 'Sin horario' },
{ label: 'Últimos 30 días', value: <StatBadges d={d} /> },
]}
actions={
@@ -325,7 +470,7 @@ const Medicos: FC = () => {
{/* Tabs */}
<div className="flex flex-wrap gap-1.5 mb-4">
{([{ key: 'info', label: 'Info' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
{([{ key: 'info', label: 'Info' }, { key: 'horario', label: 'Horario' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
<button
key={t.key}
type="button"
@@ -390,6 +535,115 @@ const Medicos: FC = () => {
</div>
</div>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Meta mensual</p>
<div className="p-4 bg-theme-bg rounded-xl flex flex-col sm:flex-row sm:items-end gap-4">
<div className="max-w-[200px]">
<Input
label="Meta del mes (m.n.)"
type="number"
min="0"
step="1000"
value={goalDraft}
onChange={(e) => setGoalDraft(e.target.value)}
/>
</div>
<Button
variant="outline"
loading={savingGoal}
onClick={() => saveGoal(selected)}
title="Guardar meta mensual"
>
<Save size={14} className="mr-1.5" />
Guardar meta
</Button>
<div className="flex-1 min-w-[180px]">
<GoalBar goal={selected.monthly_goal ?? 0} real={selected.month_total ?? 0} />
</div>
</div>
</div>
</div>
) : modalTab === 'horario' ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-medium text-theme-muted uppercase">Semana tipo (horario base)</p>
<p className="text-xs text-theme-muted mt-0.5">
Marca los días que atiende y sus horas. La agenda sólo ofrecerá esos horarios y rechazará
citas nuevas fuera de ellos. Sin días marcados, la agenda no restringe.
</p>
</div>
<Button variant="outline" size="sm" onClick={copiarLunes}>
<Copy size={14} className="mr-1.5" />
Copiar lunes a todos
</Button>
</div>
<div className="border border-theme-border rounded-xl divide-y divide-theme-border">
{DIAS.map((nombre, i) => {
const key = String(i);
const dia = dias[key] || diaDraftVacio();
const activo = dia.works;
return (
<div key={key} className="flex flex-wrap items-center gap-3 p-3">
<label className="flex items-center gap-2 min-w-[140px] cursor-pointer">
<input
type="checkbox"
checked={activo}
aria-label={`Trabaja ${nombre}`}
onChange={(e) => setDia(key, { works: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong accent-theme-accent"
/>
<span className={`text-sm font-medium ${activo ? 'text-theme-heading' : 'text-theme-muted'}`}>
{nombre}
</span>
</label>
<div className="flex items-center gap-2">
<Input
type="time"
aria-label={`Inicio ${nombre}`}
value={dia.from}
disabled={!activo}
onChange={(e) => setDia(key, { from: e.target.value })}
className="max-w-[130px]"
/>
<span className="text-theme-muted text-sm">a</span>
<Input
type="time"
aria-label={`Fin ${nombre}`}
value={dia.to}
disabled={!activo}
onChange={(e) => setDia(key, { to: e.target.value })}
className="max-w-[130px]"
/>
</div>
{!activo && <span className="text-xs text-theme-muted">No atiende</span>}
</div>
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-theme-muted">
{selected.tiene_horario
? `Horario actual: ${selected.horario_resumen}`
: 'Sin horario configurado: la agenda funciona como siempre (8:00 a 19:00).'}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDias(draftDesdeHorario([]))}
title="Quitar todos los días"
>
Limpiar
</Button>
<Button loading={savingHorario} onClick={() => saveHorario(selected)}>
<Clock size={14} className="mr-1.5" />
Guardar horario
</Button>
</div>
</div>
</div>
) : (
<div>

View File

@@ -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 = () => {
</Card>
</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 */}
<Card className="mb-4">
<Card.Header>

View File

@@ -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<InventoryItem[]>([]);
// 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}
</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>
)}

View File

@@ -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<Service | null>(null);
const [duplicando, setDuplicando] = useState(false);
const [reordenando, setReordenando] = useState(false);
const [form, setForm] = useState<ServiceForm>(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 = () => {
) : (
<>
<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.Body>
<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-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
<div className="flex items-center">
<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>
</div>
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
@@ -373,8 +467,8 @@ const Servicios: FC = () => {
{/* Modal crear/editar */}
<Modal
isOpen={modalOpen}
onClose={() => { 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
</Button>
)}
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); }}>Cancelar</Button>
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear servicio'}</Button>
{editing && (
<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">
{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
label="Nombre *"
value={form.name}
autoFocus
onFocus={(e) => e.target.select()}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

View File

@@ -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<Service>): 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;