bloque 3: lealtad configurable (motor de puntos con multiplicadores por servicio/categoria/sucursal/dia/nivel, niveles VIP con descuento y factor, expiracion, ajuste manual con motivo y reportes) + propinas en punto de venta con reparto entre el personal, desglose en ticket y corte, y opcion en la liquidacion de comisiones; modo clasico y propinas apagadas preservan el comportamiento actual
This commit is contained in:
378
frontend/src/components/LealtadPanel.tsx
Normal file
378
frontend/src/components/LealtadPanel.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Sparkles, Plus, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, Input, toast } from './ui';
|
||||
import {
|
||||
odooApi, LEALTAD_DEFAULT, type ClinicSettings, type LealtadSettings,
|
||||
type NivelLealtad, type MultiplicadorLealtad, type LealtadAlcance,
|
||||
type Service, type ServiceCategory, type Sucursal, type InventoryItem, type ReporteLealtad,
|
||||
} from '../services/odoo';
|
||||
|
||||
interface Props {
|
||||
settings: ClinicSettings;
|
||||
onSaved: (settings: ClinicSettings) => void;
|
||||
}
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
const ALCANCES: { value: LealtadAlcance; label: string }[] = [
|
||||
{ value: 'servicio', label: 'Servicio' },
|
||||
{ value: 'articulo', label: 'Artículo' },
|
||||
{ value: 'categoria', label: 'Categoría de servicio' },
|
||||
{ value: 'nivel', label: 'Nivel del cliente' },
|
||||
{ value: 'sucursal', label: 'Sucursal' },
|
||||
{ value: 'rango_fechas', label: 'Rango de fechas' },
|
||||
{ value: 'dia_semana', label: 'Día de la semana' },
|
||||
];
|
||||
|
||||
const DIAS = [
|
||||
{ v: '0', l: 'Lun' }, { v: '1', l: 'Mar' }, { v: '2', l: 'Mié' }, { v: '3', l: 'Jue' },
|
||||
{ v: '4', l: 'Vie' }, { v: '5', l: 'Sáb' }, { v: '6', l: 'Dom' },
|
||||
];
|
||||
|
||||
const APLICAR: { value: 'servicios' | 'productos' | 'membresias' | 'paquetes'; label: string }[] = [
|
||||
{ value: 'servicios', label: 'Servicios' },
|
||||
{ value: 'productos', label: 'Productos' },
|
||||
{ value: 'membresias', label: 'Membresías' },
|
||||
{ value: 'paquetes', label: 'Paquetes' },
|
||||
];
|
||||
|
||||
const nuevoNivel = (): NivelLealtad => ({
|
||||
codigo: '', nombre: '', gasto_minimo: 0, ventana_meses: 12,
|
||||
descuento_pct: 0, factor_puntos: 1, vigencia_meses: 12, color: '',
|
||||
});
|
||||
|
||||
const nuevoMultiplicador = (): MultiplicadorLealtad => ({
|
||||
nombre: '', alcance: 'servicio', valor: '', factor: 2,
|
||||
fecha_desde: '', fecha_hasta: '', activo: true,
|
||||
});
|
||||
|
||||
// Configuración → Lealtad: bloque `lealtad`. Con `modo='clasico'` (default)
|
||||
// nada cambia (1 punto por cada $10, 1 punto = $1, canje al 50%).
|
||||
const LealtadPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState<LealtadSettings>(settings.lealtad || LEALTAD_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
|
||||
const [sucursales, setSucursales] = useState<Sucursal[]>([]);
|
||||
const [reporte, setReporte] = useState<ReporteLealtad | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(settings.lealtad || LEALTAD_DEFAULT);
|
||||
}, [settings.lealtad]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getServices({ page_size: 200 }).then((r) => setServices(r.services || [])).catch(() => {});
|
||||
odooApi.getInventarioItems().then((r) => setItems(r.items || [])).catch(() => {});
|
||||
odooApi.getServiceCategories().then((r) => setCategorias(r.categories || [])).catch(() => {});
|
||||
odooApi.getSucursales().then((r) => setSucursales(r.sucursales || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cargarReporte = () => {
|
||||
odooApi.getReportLealtad().then(setReporte).catch(() => {});
|
||||
};
|
||||
useEffect(() => { cargarReporte(); }, []);
|
||||
|
||||
const set = <K extends keyof LealtadSettings>(k: K, v: LealtadSettings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
const setNivel = (i: number, patch: Partial<NivelLealtad>) =>
|
||||
setForm((prev) => ({ ...prev, niveles: prev.niveles.map((n, j) => (j === i ? { ...n, ...patch } : n)) }));
|
||||
const addNivel = () => setForm((prev) => ({ ...prev, niveles: [...prev.niveles, nuevoNivel()] }));
|
||||
const delNivel = (i: number) => setForm((prev) => ({ ...prev, niveles: prev.niveles.filter((_, j) => j !== i) }));
|
||||
|
||||
const setMul = (i: number, patch: Partial<MultiplicadorLealtad>) =>
|
||||
setForm((prev) => ({ ...prev, multiplicadores: prev.multiplicadores.map((m, j) => (j === i ? { ...m, ...patch } : m)) }));
|
||||
const addMul = () => setForm((prev) => ({ ...prev, multiplicadores: [...prev.multiplicadores, nuevoMultiplicador()] }));
|
||||
const delMul = (i: number) => setForm((prev) => ({ ...prev, multiplicadores: prev.multiplicadores.filter((_, j) => j !== i) }));
|
||||
|
||||
const guardar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await odooApi.putClinicSettings({ lealtad: form, motivo: 'Configuración de lealtad' });
|
||||
if (res.status === 'success') {
|
||||
onSaved(res.settings);
|
||||
toast.success('Programa de lealtad guardado');
|
||||
cargarReporte();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la lealtad');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clasico = form.modo !== 'configurable';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<Sparkles size={18} /> Lealtad y niveles VIP
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">
|
||||
En modo clásico nada cambia: 1 punto por cada $10, 1 punto = $1 y canje máximo del 50%.
|
||||
El modo configurable usa los valores de abajo.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle label="Programa de lealtad activo" checked={form.activo}
|
||||
onChange={(v) => set('activo', v)}
|
||||
ayuda="Apagado, no se acumulan puntos por compra." />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-xl">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Modo</label>
|
||||
<select
|
||||
value={form.modo}
|
||||
onChange={(e) => set('modo', e.target.value as LealtadSettings['modo'])}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
|
||||
>
|
||||
<option value="clasico">Clásico (comportamiento actual)</option>
|
||||
<option value="configurable">Configurable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Redondeo de puntos</label>
|
||||
<select
|
||||
value={form.redondear}
|
||||
onChange={(e) => set('redondear', e.target.value as LealtadSettings['redondear'])}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
|
||||
disabled={clasico}
|
||||
>
|
||||
<option value="abajo">Hacia abajo (truncar)</option>
|
||||
<option value="normal">Normal (redondear)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clasico ? (
|
||||
<div className="rounded-xl border border-theme-border bg-theme-bg p-3 text-sm text-theme-muted">
|
||||
Modo clásico: 1 punto por cada $10 pagados, 1 punto = $1, canje máximo del 50% del total,
|
||||
sin niveles ni multiplicadores. Es idéntico al comportamiento de hoy.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input label="Puntos por peso" type="number" step="0.01"
|
||||
value={String(form.puntos_por_unidad)}
|
||||
onChange={(e) => set('puntos_por_unidad', Number(e.target.value) || 0)} />
|
||||
<Input label="Valor del punto ($)" type="number" step="0.01"
|
||||
value={String(form.valor_punto)}
|
||||
onChange={(e) => set('valor_punto', Number(e.target.value) || 0)} />
|
||||
<Input label="Canje máximo (%)" type="number" step="1" min={0} max={100}
|
||||
value={String(form.canje_max_pct)}
|
||||
onChange={(e) => set('canje_max_pct', Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input label="Expiración (meses, 0 = nunca)" type="number" min={0}
|
||||
value={String(form.expira_meses)}
|
||||
onChange={(e) => set('expira_meses', Math.max(0, Math.floor(Number(e.target.value) || 0)))} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Acumular sobre</label>
|
||||
<select value={form.acumular_sobre}
|
||||
onChange={(e) => set('acumular_sobre', e.target.value as LealtadSettings['acumular_sobre'])}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="pagado">Lo pagado</option>
|
||||
<option value="vendido">Lo vendido</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Moneda base</label>
|
||||
<select value={form.moneda_base}
|
||||
onChange={(e) => set('moneda_base', e.target.value)}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-1.5">Aplica a</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{APLICAR.map((a) => {
|
||||
const on = form.aplicar_a.includes(a.value);
|
||||
return (
|
||||
<button key={a.value} type="button"
|
||||
onClick={() => set('aplicar_a', on ? form.aplicar_a.filter((x) => x !== a.value) : [...form.aplicar_a, a.value])}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border ${on ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
|
||||
{a.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Niveles VIP */}
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">Niveles VIP</p>
|
||||
<Button size="sm" variant="outline" onClick={addNivel} disabled={clasico}><Plus size={14} className="mr-1" />Nivel</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{clasico ? (
|
||||
<p className="text-sm text-theme-muted">Cambia a modo configurable para definir niveles.</p>
|
||||
) : form.niveles.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin niveles. Un cliente sin nivel no tiene descuento ni factor extra.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{form.niveles.map((n, i) => (
|
||||
<div key={i} className="grid grid-cols-2 sm:grid-cols-7 gap-2 items-end border border-theme-border rounded-xl p-3">
|
||||
<Input label="Código" value={n.codigo} onChange={(e) => setNivel(i, { codigo: e.target.value })} />
|
||||
<Input label="Nombre" value={n.nombre} onChange={(e) => setNivel(i, { nombre: e.target.value })} />
|
||||
<Input label="Gasto mínimo" type="number" value={String(n.gasto_minimo)}
|
||||
onChange={(e) => setNivel(i, { gasto_minimo: Number(e.target.value) || 0 })} />
|
||||
<Input label="Ventana (meses)" type="number" value={String(n.ventana_meses)}
|
||||
onChange={(e) => setNivel(i, { ventana_meses: Math.max(1, Number(e.target.value) || 12) })} />
|
||||
<Input label="Descuento %" type="number" value={String(n.descuento_pct)}
|
||||
onChange={(e) => setNivel(i, { descuento_pct: Number(e.target.value) || 0 })} />
|
||||
<Input label="Factor puntos" type="number" step="0.1" value={String(n.factor_puntos)}
|
||||
onChange={(e) => setNivel(i, { factor_puntos: Number(e.target.value) || 1 })} />
|
||||
<div className="flex items-end gap-2">
|
||||
<Input label="Vigencia (meses)" type="number" value={String(n.vigencia_meses)}
|
||||
onChange={(e) => setNivel(i, { vigencia_meses: Math.max(0, Number(e.target.value) || 0) })} />
|
||||
<Button size="sm" variant="ghost" onClick={() => delNivel(i)} title="Quitar"><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Multiplicadores */}
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">Multiplicadores de puntos</p>
|
||||
<Button size="sm" variant="outline" onClick={addMul} disabled={clasico}><Plus size={14} className="mr-1" />Regla</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{clasico ? (
|
||||
<p className="text-sm text-theme-muted">Cambia a modo configurable para definir multiplicadores.</p>
|
||||
) : form.multiplicadores.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin reglas. Gana la más específica: servicio > artículo > categoría > nivel > sucursal > rango de fechas > día.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{form.multiplicadores.map((m, i) => (
|
||||
<div key={i} className="grid grid-cols-2 sm:grid-cols-6 gap-2 items-end border border-theme-border rounded-xl p-3">
|
||||
<Input label="Nombre" value={m.nombre} onChange={(e) => setMul(i, { nombre: e.target.value })} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-theme-muted mb-1">Alcance</label>
|
||||
<select value={m.alcance} onChange={(e) => setMul(i, { alcance: e.target.value as LealtadAlcance, valor: '' })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
{ALCANCES.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-theme-muted mb-1">Valor</label>
|
||||
{m.alcance === 'servicio' ? (
|
||||
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
<option value="">—</option>
|
||||
{services.map((s) => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
|
||||
</select>
|
||||
) : m.alcance === 'articulo' ? (
|
||||
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
<option value="">—</option>
|
||||
{items.map((s) => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
|
||||
</select>
|
||||
) : m.alcance === 'categoria' ? (
|
||||
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
<option value="">—</option>
|
||||
{categorias.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
</select>
|
||||
) : m.alcance === 'nivel' ? (
|
||||
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
<option value="">—</option>
|
||||
{form.niveles.map((n) => <option key={n.codigo} value={n.codigo}>{n.nombre || n.codigo}</option>)}
|
||||
</select>
|
||||
) : m.alcance === 'sucursal' ? (
|
||||
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
|
||||
<option value="">—</option>
|
||||
{sucursales.map((s) => <option key={s.code} value={s.code}>{s.name}</option>)}
|
||||
</select>
|
||||
) : m.alcance === 'dia_semana' ? (
|
||||
<Input value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
|
||||
placeholder={DIAS.map((d) => d.v).join(',')} />
|
||||
) : (
|
||||
<Input value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })} placeholder="—" />
|
||||
)}
|
||||
</div>
|
||||
<Input label="Factor" type="number" step="0.1" value={String(m.factor)}
|
||||
onChange={(e) => setMul(i, { factor: Number(e.target.value) || 1 })} />
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<Input label="Desde" type="date" value={m.fecha_desde}
|
||||
onChange={(e) => setMul(i, { fecha_desde: e.target.value })} />
|
||||
<Input label="Hasta" type="date" value={m.fecha_hasta}
|
||||
onChange={(e) => setMul(i, { fecha_hasta: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Toggle label="Activa" checked={m.activo} onChange={(v) => setMul(i, { activo: v })} />
|
||||
<Button size="sm" variant="ghost" onClick={() => delMul(i)} title="Quitar"><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Resumen del reporte de lealtad */}
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">Resumen de puntos</p>
|
||||
<Button size="sm" variant="ghost" onClick={cargarReporte}><RefreshCw size={14} className="mr-1" />Actualizar</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{reporte ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div><p className="text-theme-muted">Otorgados</p><p className="font-heading text-lg">{reporte.otorgados}</p></div>
|
||||
<div><p className="text-theme-muted">Canjeados</p><p className="font-heading text-lg">{reporte.canjeados}</p></div>
|
||||
<div><p className="text-theme-muted">Expirados</p><p className="font-heading text-lg">{reporte.expirados}</p></div>
|
||||
<div><p className="text-theme-muted">Pasivo (pesos)</p><p className="font-heading text-lg">${reporte.pasivo_mxn.toFixed(2)}</p></div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-theme-muted">Sin datos.</p>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LealtadPanel;
|
||||
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight,
|
||||
Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, Pencil,
|
||||
Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, Pencil, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWallet, type ReceiptData, type Appointment, type Sale, type Payment } from '../services/odoo';
|
||||
import { Badge, Button, Skeleton } from './ui';
|
||||
@@ -357,6 +357,12 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Wallet size={11} /> Monedero</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{cuenta?.puntos_monedero ?? 0} pts</p>
|
||||
{wallet?.nivel?.nombre && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
|
||||
<Sparkles size={10} /> {wallet.nivel.nombre}
|
||||
{wallet.nivel.descuento_pct > 0 ? ` · ${wallet.nivel.descuento_pct}%` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Saldo pendiente</p>
|
||||
|
||||
160
frontend/src/components/PropinasPanel.tsx
Normal file
160
frontend/src/components/PropinasPanel.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, HandCoins, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, Input, toast } from './ui';
|
||||
import {
|
||||
odooApi, PROPINAS_DEFAULT, type ClinicSettings, type PropinasSettings,
|
||||
type ReportePropinas,
|
||||
} from '../services/odoo';
|
||||
|
||||
interface Props {
|
||||
settings: ClinicSettings;
|
||||
onSaved: (settings: ClinicSettings) => void;
|
||||
}
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
// Configuración → Propinas: bloque `propinas`. Con `activo=false` (default) el
|
||||
// punto de venta se ve y se comporta exactamente igual que hoy.
|
||||
const PropinasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState<PropinasSettings>(settings.propinas || PROPINAS_DEFAULT);
|
||||
const [sugerencias, setSugerencias] = useState((settings.propinas || PROPINAS_DEFAULT).sugerencias_pct.join(', '));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reporte, setReporte] = useState<ReportePropinas | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const p = settings.propinas || PROPINAS_DEFAULT;
|
||||
setForm(p);
|
||||
setSugerencias(p.sugerencias_pct.join(', '));
|
||||
}, [settings.propinas]);
|
||||
|
||||
const cargarReporte = () => { odooApi.getReportPropinas().then(setReporte).catch(() => {}); };
|
||||
useEffect(() => { cargarReporte(); }, []);
|
||||
|
||||
const set = <K extends keyof PropinasSettings>(k: K, v: PropinasSettings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
const guardar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const pct = sugerencias.split(',').map((v) => parseFloat(v.trim())).filter((v) => !Number.isNaN(v) && v >= 0);
|
||||
const res = await odooApi.putClinicSettings({
|
||||
propinas: { ...form, sugerencias_pct: pct.length ? pct : [0] },
|
||||
motivo: 'Configuración de propinas',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
onSaved(res.settings);
|
||||
toast.success('Configuración de propinas guardada');
|
||||
cargarReporte();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudieron guardar las propinas');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<HandCoins size={18} /> Propinas en el punto de venta
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">
|
||||
Apagado (default) el cobro se ve exactamente igual que hoy: sin campos de propina y con
|
||||
el mismo total. Encendido, la propina se registra aparte de los servicios.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle label="Activar propinas" checked={form.activo}
|
||||
onChange={(v) => set('activo', v)}
|
||||
ayuda="Muestra el campo de propina al cobrar en el punto de venta." />
|
||||
<Toggle label="Permitir monto personalizado" checked={form.permitir_personalizado}
|
||||
onChange={(v) => set('permitir_personalizado', v)}
|
||||
ayuda="Además de los porcentajes sugeridos, permite capturar un monto libre." />
|
||||
<Toggle label="Permitir cobrar sin propina" checked={form.permitir_sin_propina}
|
||||
onChange={(v) => set('permitir_sin_propina', v)}
|
||||
ayuda="Si se apaga, el cobro exige registrar propina." />
|
||||
<Toggle label="Incluir la propina en la liquidación de comisiones" checked={form.incluir_en_liquidacion_comisiones}
|
||||
onChange={(v) => set('incluir_en_liquidacion_comisiones', v)}
|
||||
ayuda="La propina del empleado se suma a su liquidación. No altera el cálculo de la comisión." />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input label="Porcentajes sugeridos (%)" value={sugerencias}
|
||||
onChange={(e) => setSugerencias(e.target.value)} placeholder="10, 15, 20" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Reparto entre el personal</label>
|
||||
<select value={form.reparto} onChange={(e) => set('reparto', e.target.value as PropinasSettings['reparto'])}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="ninguno">Sin reparto (queda en la venta)</option>
|
||||
<option value="equitativo">Equitativo entre implicados</option>
|
||||
<option value="por_participacion">Por participación en la venta</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input label="Cuenta destino de propinas" value={form.cuenta_propinas}
|
||||
onChange={(e) => set('cuenta_propinas', e.target.value)} placeholder="Opcional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-theme-heading">Propinas del mes por empleado</p>
|
||||
<Button size="sm" variant="ghost" onClick={cargarReporte}><RefreshCw size={14} className="mr-1" />Actualizar</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{!reporte ? (
|
||||
<p className="text-sm text-theme-muted">Sin datos.</p>
|
||||
) : reporte.por_empleado.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin propinas registradas en el periodo.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Empleado</th>
|
||||
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Propinas</th>
|
||||
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{reporte.por_empleado.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="p-2 text-theme-heading">{r.empleado}</td>
|
||||
<td className="p-2 text-right text-theme-muted">{r.propinas}</td>
|
||||
<td className="p-2 text-right font-medium text-theme-heading">${r.monto.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropinasPanel;
|
||||
@@ -22,6 +22,10 @@ export interface TicketData {
|
||||
/** Impuesto del ticket y su etiqueta (vacío cuando los impuestos están apagados) */
|
||||
tax?: number;
|
||||
tax_name?: string;
|
||||
/** Descuento automático por nivel VIP (0 si no aplica). */
|
||||
nivel_discount?: number;
|
||||
/** Propina agregada al cobro (0 si el módulo está apagado). */
|
||||
propina?: number;
|
||||
total: number;
|
||||
metodo?: string | null;
|
||||
puntos_usados?: number;
|
||||
@@ -176,9 +180,15 @@ export const TicketDoc: FC<TicketDocProps> = ({ data, config, preview }) => {
|
||||
{on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && (
|
||||
<Fila izquierda="Puntos canjeados" derecha={`-${data.puntos_usados} pts`} />
|
||||
)}
|
||||
{(data.nivel_discount ?? 0) > 0 && (
|
||||
<Fila izquierda="Descuento de nivel" derecha={`-${fmtMXN(data.nivel_discount!)}`} />
|
||||
)}
|
||||
{on('impuesto') && (data.tax ?? 0) > 0 && (
|
||||
<Fila izquierda={data.tax_name || 'Impuesto'} derecha={fmtMXN(data.tax!)} />
|
||||
)}
|
||||
{(data.propina ?? 0) > 0 && (
|
||||
<Fila izquierda="Propina" derecha={fmtMXN(data.propina!)} />
|
||||
)}
|
||||
{on('total') && <Fila izquierda="Total" derecha={fmtMXN(data.total)} fuerte />}
|
||||
{on('total_usd') && data.rate_used ? (
|
||||
<Fila
|
||||
|
||||
131
frontend/src/components/reportes/LealtadReport.tsx
Normal file
131
frontend/src/components/reportes/LealtadReport.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Card, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type ReporteLealtad } from '../../services/odoo';
|
||||
import { useGlobalReportRange } from '../../lib/reportRange';
|
||||
|
||||
// Reporte de lealtad: puntos otorgados, canjeados y expirados en el periodo,
|
||||
// más el pasivo en pesos (puntos activos × valor del punto) y el conteo por
|
||||
// nivel. En modo clásico las cifras coinciden con las de siempre (valor = $1).
|
||||
const LealtadReport: FC = () => {
|
||||
const { start, end } = useGlobalReportRange();
|
||||
const [data, setData] = useState<ReporteLealtad | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getReportLealtad(start, end);
|
||||
if (res.status === 'success') setData(res);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar el reporte de lealtad');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `lealtad-${start}_${end}`,
|
||||
sheetName: 'Lealtad',
|
||||
title: 'Reporte de lealtad',
|
||||
subtitle: `${start} a ${end}`,
|
||||
columns: [
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Valor', key: 'valor', format: 'number' },
|
||||
],
|
||||
rows: [
|
||||
{ concepto: 'Puntos otorgados', valor: data.otorgados },
|
||||
{ concepto: 'Puntos canjeados', valor: data.canjeados },
|
||||
{ concepto: 'Puntos expirados', valor: data.expirados },
|
||||
{ concepto: 'Ajustes manuales', valor: data.ajustes },
|
||||
{ concepto: 'Depósitos (saldo a favor)', valor: data.depositos },
|
||||
{ concepto: 'Puntos activos', valor: data.puntos_activos },
|
||||
{ concepto: 'Pasivo en pesos', valor: data.pasivo_mxn },
|
||||
],
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
|
||||
<Sparkles size={18} /> Lealtad y puntos
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-theme-muted">{start} a {end}</p>
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-20 w-full" />
|
||||
) : !data ? (
|
||||
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte." icon={<Sparkles size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Otorgados</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{data.otorgados}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Canjeados</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{data.canjeados}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Expirados</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{data.expirados}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Pasivo (pesos)</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${data.pasivo_mxn.toFixed(2)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Saldo activo</p>
|
||||
<p className="text-sm text-theme-muted">{data.puntos_activos} puntos en {data.cuentas} cuentas · valor del punto ${data.valor_punto.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Clientes por nivel</p>
|
||||
{data.niveles.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin niveles configurados.</p>
|
||||
) : (
|
||||
<ul className="text-sm text-theme-muted space-y-1">
|
||||
{data.niveles.map((n) => (
|
||||
<li key={n.codigo} className="flex justify-between">
|
||||
<span>{n.nombre || n.codigo}</span>
|
||||
<span className="font-medium text-theme-heading">{data.conteo_niveles[n.codigo] || 0}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LealtadReport;
|
||||
156
frontend/src/components/reportes/PropinasReport.tsx
Normal file
156
frontend/src/components/reportes/PropinasReport.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { HandCoins } from 'lucide-react';
|
||||
import { Card, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type ReportePropinas } from '../../services/odoo';
|
||||
import { useGlobalReportRange } from '../../lib/reportRange';
|
||||
|
||||
// Reporte de propinas: total cobrado de propina, reparto por empleado y el
|
||||
// detalle (quién, cuánto y de qué venta). Exportable a Excel.
|
||||
const PropinasReport: FC = () => {
|
||||
const { start, end } = useGlobalReportRange();
|
||||
const [data, setData] = useState<ReportePropinas | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getReportPropinas(start, end);
|
||||
if (res.status === 'success') setData(res);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar el reporte de propinas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `propinas-${start}_${end}`,
|
||||
sheetName: 'Propinas',
|
||||
title: 'Propinas por empleado',
|
||||
subtitle: `${start} a ${end}`,
|
||||
columns: [
|
||||
{ header: 'Empleado', key: 'empleado' },
|
||||
{ header: 'Propinas', key: 'propinas', format: 'number' },
|
||||
{ header: 'Monto', key: 'monto', format: 'number' },
|
||||
],
|
||||
rows: data.por_empleado.map((r) => ({
|
||||
empleado: r.empleado, propinas: r.propinas, monto: r.monto,
|
||||
})),
|
||||
totals: { empleado: 'Total', propinas: '', monto: data.total_repartido },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
|
||||
<HandCoins size={18} /> Propinas por empleado
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-theme-muted">{start} a {end}</p>
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-12 w-full" />
|
||||
) : !data ? (
|
||||
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte." icon={<HandCoins size={28} />} />
|
||||
) : !data.activo ? (
|
||||
<EmptyState title="Propinas apagadas" subtitle="Activa el módulo de propinas para comenzar a registrar." icon={<HandCoins size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Propina total del periodo</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${data.total.toFixed(2)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Repartida</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${data.total_repartido.toFixed(2)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Ventas con propina</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{data.total_ventas}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{data.por_empleado.length === 0 ? (
|
||||
<EmptyState title="Sin reparto" subtitle="No hay propinas repartidas entre el personal en el periodo." icon={<HandCoins size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Empleado</th>
|
||||
<th className="text-right px-4 py-3">Propinas</th>
|
||||
<th className="text-right px-4 py-3">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.por_empleado.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{r.empleado}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.propinas}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">${r.monto.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.detalle.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Detalle</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Fecha</th>
|
||||
<th className="text-left px-3 py-2">Folio</th>
|
||||
<th className="text-left px-3 py-2">Empleado</th>
|
||||
<th className="text-left px-3 py-2">Rol</th>
|
||||
<th className="text-right px-3 py-2">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.detalle.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 text-theme-muted">{r.date}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{r.folio}</td>
|
||||
<td className="px-3 py-2 text-theme-heading">{r.empleado || '—'}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{r.rol}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">${r.monto.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropinasReport;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
|
||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||
@@ -23,6 +23,8 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
|
||||
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
|
||||
licencia: {
|
||||
...LICENCIA_DEFAULT,
|
||||
...(settings?.licencia || {}),
|
||||
|
||||
@@ -325,6 +325,8 @@ export const es: Dict = {
|
||||
'config.tabs.membresias': 'Membresías',
|
||||
'config.tabs.compras': 'Compras',
|
||||
'config.tabs.comisiones': 'Comisiones',
|
||||
'config.tabs.lealtad': 'Lealtad',
|
||||
'config.tabs.propinas': 'Propinas',
|
||||
'config.tabs.terminologia': 'Terminología',
|
||||
'config.tabs.usuarios': 'Usuarios',
|
||||
'config.tabs.roles': 'Roles',
|
||||
@@ -422,6 +424,12 @@ export const es: Dict = {
|
||||
'reports.tab.vendedores.desc': 'Ventas atribuidas por vendedor',
|
||||
'reports.tab.kpis': 'KPIs',
|
||||
'reports.tab.kpis.desc': 'No-show, ocupación y conversión',
|
||||
'reports.family.lealtad': 'Lealtad',
|
||||
'reports.family.lealtad.hint': 'Puntos y propinas',
|
||||
'reports.tab.lealtad': 'Puntos',
|
||||
'reports.tab.lealtad.desc': 'Puntos otorgados, canjeados, expirados y pasivo en pesos',
|
||||
'reports.tab.propinas': 'Propinas',
|
||||
'reports.tab.propinas.desc': 'Propinas por empleado y periodo',
|
||||
'reports.tab.exportar': 'Exportar',
|
||||
'reports.tab.exportar.desc': 'Exportar datos',
|
||||
|
||||
@@ -530,6 +538,9 @@ export const es: Dict = {
|
||||
'pos.points': 'Puntos',
|
||||
'pos.discount': 'Descuento',
|
||||
'pos.taxIncluded': ' (incluido)',
|
||||
'pos.tip': 'Propina',
|
||||
'pos.noTip': 'Sin propina',
|
||||
'pos.tipCustom': 'Propina (monto libre)',
|
||||
'pos.chargeAmount': 'Cobrar {amount}',
|
||||
'pos.chargeTitle': 'Cobrar (F9)',
|
||||
'pos.noSellPerm': 'Sin permiso para vender',
|
||||
@@ -1122,6 +1133,8 @@ export const en: Dict = {
|
||||
'config.tabs.membresias': 'Memberships',
|
||||
'config.tabs.compras': 'Purchases',
|
||||
'config.tabs.comisiones': 'Commissions',
|
||||
'config.tabs.lealtad': 'Loyalty',
|
||||
'config.tabs.propinas': 'Tips',
|
||||
'config.tabs.terminologia': 'Terminology',
|
||||
'config.tabs.usuarios': 'Users',
|
||||
'config.tabs.roles': 'Roles',
|
||||
@@ -1219,6 +1232,12 @@ export const en: Dict = {
|
||||
'reports.tab.vendedores.desc': 'Sales attributed per seller',
|
||||
'reports.tab.kpis': 'KPIs',
|
||||
'reports.tab.kpis.desc': 'No-show, occupancy and conversion',
|
||||
'reports.family.lealtad': 'Loyalty',
|
||||
'reports.family.lealtad.hint': 'Points and tips',
|
||||
'reports.tab.lealtad': 'Points',
|
||||
'reports.tab.lealtad.desc': 'Points earned, redeemed, expired and liability in pesos',
|
||||
'reports.tab.propinas': 'Tips',
|
||||
'reports.tab.propinas.desc': 'Tips per employee and period',
|
||||
'reports.tab.exportar': 'Export',
|
||||
'reports.tab.exportar.desc': 'Export data',
|
||||
|
||||
@@ -1324,6 +1343,9 @@ export const en: Dict = {
|
||||
'pos.points': 'Points',
|
||||
'pos.discount': 'Discount',
|
||||
'pos.taxIncluded': ' (included)',
|
||||
'pos.tip': 'Tip',
|
||||
'pos.noTip': 'No tip',
|
||||
'pos.tipCustom': 'Tip (custom amount)',
|
||||
'pos.chargeAmount': 'Charge {amount}',
|
||||
'pos.chargeTitle': 'Charge (F9)',
|
||||
'pos.noSellPerm': 'No permission to sell',
|
||||
|
||||
@@ -24,6 +24,8 @@ export const PERM_KEYS: string[] = [
|
||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
||||
'lealtad.ver', 'lealtad.ajustar', 'lealtad.nivel', 'lealtad.configurar',
|
||||
'propinas.ver', 'propinas.aplicar', 'propinas.configurar',
|
||||
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
'api.claves', 'webhooks.editar',
|
||||
@@ -56,6 +58,8 @@ const READ_PERM_KEYS = new Set([
|
||||
'proveedores.ver',
|
||||
'comisiones.ver',
|
||||
'cobros.ver',
|
||||
'lealtad.ver',
|
||||
'propinas.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
@@ -89,6 +93,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'compras.editar': AG, 'compras.cancelar': AG,
|
||||
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
||||
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
||||
'lealtad.ver': ALL, 'lealtad.ajustar': AG, 'lealtad.nivel': AG, 'lealtad.configurar': AG,
|
||||
'propinas.ver': ALL, 'propinas.aplicar': MONEY, 'propinas.configurar': AG,
|
||||
'cobros.ver': ALL, 'cobros.administrar': AG,
|
||||
'cobros.forzar': ['admin', 'recepcion', 'gerencia'], 'cobros.cancelar': AG,
|
||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||
|
||||
@@ -22,6 +22,8 @@ import FormulariosPanel from '../components/FormulariosPanel';
|
||||
import MembresiasPanel from '../components/MembresiasPanel';
|
||||
import ComprasPanel from '../components/ComprasPanel';
|
||||
import ComisionesPanel from '../components/ComisionesPanel';
|
||||
import LealtadPanel from '../components/LealtadPanel';
|
||||
import PropinasPanel from '../components/PropinasPanel';
|
||||
import LicenciaPanel from '../components/LicenciaPanel';
|
||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||
import WebhooksPanel from '../components/WebhooksPanel';
|
||||
@@ -45,6 +47,8 @@ const TABS = [
|
||||
{ key: 'membresias', label: 'Membresías' },
|
||||
{ key: 'compras', label: 'Compras' },
|
||||
{ key: 'comisiones', label: 'Comisiones' },
|
||||
{ key: 'lealtad', label: 'Lealtad' },
|
||||
{ key: 'propinas', label: 'Propinas' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
@@ -58,7 +62,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -88,6 +92,8 @@ const Configuracion: FC = () => {
|
||||
membresias: 'membresias.editar',
|
||||
compras: 'compras.editar',
|
||||
comisiones: 'comisiones.editar',
|
||||
lealtad: 'lealtad.configurar',
|
||||
propinas: 'propinas.configurar',
|
||||
recursos: 'recursos.editar',
|
||||
licencia: 'config.editar',
|
||||
api: 'api.claves',
|
||||
@@ -440,6 +446,18 @@ const Configuracion: FC = () => {
|
||||
<ComisionesPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'lealtad' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<LealtadPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'propinas' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<PropinasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
|
||||
@@ -520,8 +520,17 @@ const MonederoTab: FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { data, loading } = useFetch(() => portalApi.monedero());
|
||||
if (loading || !data) return <Loading />;
|
||||
const nivel = data.nivel as { codigo: string; nombre: string; descuento_pct: number } | null | undefined;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{nivel?.nombre && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-theme-accent-bg px-3 py-1 text-sm font-medium text-theme-heading">
|
||||
<Sparkles size={14} /> {nivel.nombre}
|
||||
{nivel.descuento_pct > 0 && <span className="text-theme-muted">· {nivel.descuento_pct}%</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.monedero.points')}</p>
|
||||
|
||||
@@ -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, TrendingUp, LayoutGrid, List, Layers, Repeat,
|
||||
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers, Repeat, HandCoins,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
@@ -60,6 +60,8 @@ const Pos: FC = () => {
|
||||
const metodoLabelL = (m: string) => metodoOpts.find((o) => o.value === m)?.label || m;
|
||||
const impuestos = useClinicSettings().impuestos;
|
||||
const membresiasCfg = useClinicSettings().membresias;
|
||||
// Propinas: apagadas por defecto (el POS se ve y se comporta como hoy).
|
||||
const propinasCfg = useClinicSettings().propinas;
|
||||
const puedeImpuestos = can('ventas.impuestos');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Catálogo
|
||||
@@ -112,6 +114,8 @@ const Pos: FC = () => {
|
||||
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
|
||||
const [recibido, setRecibido] = useState('');
|
||||
const [conPuntos, setConPuntos] = useState(false);
|
||||
const [propinaPct, setPropinaPct] = useState<number | ''>('');
|
||||
const [propinaMonto, setPropinaMonto] = useState('');
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
const [tcRate, setTcRate] = useState<number | null>(null);
|
||||
|
||||
@@ -415,6 +419,15 @@ const Pos: FC = () => {
|
||||
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
|
||||
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
|
||||
const total = Math.max(0, subtotal - discountNum + impuesto);
|
||||
// Propina (solo con el módulo encendido). El total cobrado la incluye; las
|
||||
// cifras de servicios (`total`) no cambian.
|
||||
const propinasActivas = propinasCfg.activo;
|
||||
const propinaNum = !propinasActivas
|
||||
? 0
|
||||
: propinaPct !== ''
|
||||
? Math.round(total * (Number(propinaPct) || 0)) / 100
|
||||
: (parseFloat(propinaMonto) || 0);
|
||||
const totalCobro = Math.max(0, total + propinaNum);
|
||||
const numItems = lines.reduce((a, l) => a + l.qty, 0);
|
||||
const puntosPaciente = paciente?.wallet_points ?? 0;
|
||||
const puedePuntos = puntosPaciente >= total && total > 0;
|
||||
@@ -425,7 +438,7 @@ const Pos: FC = () => {
|
||||
? (parseFloat(recibido) || 0) * tcRate
|
||||
: parseFloat(recibido) || 0
|
||||
: 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - totalCobro) : 0;
|
||||
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
|
||||
|
||||
const crearPacienteRapido = async () => {
|
||||
@@ -460,6 +473,8 @@ const Pos: FC = () => {
|
||||
setMoneda('MXN');
|
||||
setRecibido('');
|
||||
setConPuntos(false);
|
||||
setPropinaPct('');
|
||||
setPropinaMonto('');
|
||||
setCobroOpen(true);
|
||||
};
|
||||
|
||||
@@ -484,6 +499,14 @@ const Pos: FC = () => {
|
||||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||||
pay_with_points: conPuntos,
|
||||
currency: moneda,
|
||||
...(propinasActivas && propinaNum > 0
|
||||
? (propinaPct !== ''
|
||||
? { propina_pct: Number(propinaPct) || 0 }
|
||||
: { propina: propinaNum })
|
||||
: {}),
|
||||
// El doctor que atendió también es el empleado al que se atribuye la
|
||||
// propina cuando el cobro no trae una cita con su propio doctor.
|
||||
...(doctorId ? { propina_employee_id: parseInt(doctorId, 10), cobrador_employee_id: parseInt(doctorId, 10) } : {}),
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setResultado(res);
|
||||
@@ -975,15 +998,21 @@ const Pos: FC = () => {
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{propinaNum > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-theme-muted">{t('pos.tip')}</span>
|
||||
<span className="text-theme-heading">{fmtMoney(propinaNum)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-theme-heading">{t('common.total')}</span>
|
||||
<span className="text-right">
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(totalCobro)}</span>
|
||||
<span
|
||||
className="block text-xs text-theme-muted"
|
||||
title={toUsd(total) === null ? t('pos.setRate') : undefined}
|
||||
title={toUsd(totalCobro) === null ? t('pos.setRate') : undefined}
|
||||
>
|
||||
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
|
||||
{toUsd(totalCobro) !== null ? `≈ ${fmtUsd(toUsd(totalCobro)!)}` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -997,7 +1026,7 @@ const Pos: FC = () => {
|
||||
title={can('ventas.vender') ? t('pos.chargeTitle') : t('pos.noSellPerm')}
|
||||
>
|
||||
<ShoppingBag size={18} className="mr-2" />
|
||||
{t('pos.chargeAmount', { amount: fmtMoney(total) })}
|
||||
{t('pos.chargeAmount', { amount: fmtMoney(totalCobro) })}
|
||||
</Button>
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
||||
<Keyboard size={11} aria-hidden="true" />
|
||||
@@ -1371,7 +1400,7 @@ const Pos: FC = () => {
|
||||
onClick={() => setTicketMovil(true)}
|
||||
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
|
||||
>
|
||||
Ver ticket ({numItems}) {fmtMoney(total)}
|
||||
Ver ticket ({numItems}) {fmtMoney(totalCobro)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1445,7 +1474,7 @@ const Pos: FC = () => {
|
||||
<Modal
|
||||
isOpen={cobroOpen}
|
||||
onClose={() => setCobroOpen(false)}
|
||||
title={t('pos.chargeAmount', { amount: fmtMoney(total) })}
|
||||
title={t('pos.chargeAmount', { amount: fmtMoney(totalCobro) })}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
@@ -1462,6 +1491,37 @@ const Pos: FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Select label={t('pos.methodLabel')} options={metodoOpts} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{propinasActivas && (
|
||||
<div className="border border-theme-border rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-theme-heading flex items-center gap-1.5">
|
||||
<HandCoins size={15} className="text-theme-muted" />{t('pos.tip')}
|
||||
</p>
|
||||
{propinaNum > 0 && <span className="text-sm font-semibold text-theme-heading">{fmtMoney(propinaNum)}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{propinasCfg.sugerencias_pct.map((p) => (
|
||||
<button key={p} type="button"
|
||||
onClick={() => { setPropinaPct(p); setPropinaMonto(''); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border ${propinaPct === p ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
|
||||
{p}%
|
||||
</button>
|
||||
))}
|
||||
{propinasCfg.permitir_sin_propina && (
|
||||
<button type="button"
|
||||
onClick={() => { setPropinaPct(''); setPropinaMonto(''); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border ${propinaPct === '' && !propinaMonto ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
|
||||
{t('pos.noTip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{propinasCfg.permitir_personalizado && (
|
||||
<Input label={t('pos.tipCustom')} type="number" min={0} step={10} value={propinaMonto}
|
||||
onChange={(e) => { setPropinaMonto(e.target.value); setPropinaPct(''); }}
|
||||
placeholder="0" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{metodo === 'cash' && !conPuntos && (
|
||||
<>
|
||||
<div>
|
||||
@@ -1499,7 +1559,7 @@ const Pos: FC = () => {
|
||||
step={10}
|
||||
value={recibido}
|
||||
onChange={(e) => setRecibido(e.target.value)}
|
||||
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
|
||||
placeholder={moneda === 'USD' && tcRate ? (totalCobro / tcRate).toFixed(2) : String(totalCobro)}
|
||||
/>
|
||||
{moneda === 'USD' && recibido && tcRate && (
|
||||
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
|
||||
@@ -1556,6 +1616,8 @@ const Pos: FC = () => {
|
||||
})),
|
||||
subtotal: resultado.sale.subtotal,
|
||||
discount: resultado.sale.discount,
|
||||
nivel_discount: resultado.sale.nivel_discount ?? resultado.nivel_discount ?? 0,
|
||||
propina: resultado.sale.propina ?? resultado.propina ?? 0,
|
||||
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
|
||||
tax_name: impuestos.nombre,
|
||||
total: resultado.sale.total,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react';
|
||||
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -38,6 +38,8 @@ import RecursosReport from '../components/reportes/RecursosReport';
|
||||
import ComprasReport from '../components/reportes/ComprasReport';
|
||||
import MargenReport from '../components/reportes/MargenReport';
|
||||
import CobrosReport from '../components/reportes/CobrosReport';
|
||||
import LealtadReport from '../components/reportes/LealtadReport';
|
||||
import PropinasReport from '../components/reportes/PropinasReport';
|
||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { CommissionRow } from '../services/odoo';
|
||||
@@ -126,6 +128,16 @@ const FAMILIAS: ReportFamily[] = [
|
||||
{ key: 'kpis', label: 'KPIs', desc: 'No-show, ocupación y conversión' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lealtad',
|
||||
label: 'Lealtad',
|
||||
hint: 'Puntos y propinas',
|
||||
icon: Sparkles,
|
||||
tabs: [
|
||||
{ key: 'lealtad', label: 'Puntos', desc: 'Puntos otorgados, canjeados, expirados y pasivo en pesos' },
|
||||
{ key: 'propinas', label: 'Propinas', desc: 'Propinas por empleado y periodo' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 'exportar' no vive en ninguna familia: se abre desde el botón del encabezado
|
||||
@@ -157,7 +169,7 @@ interface CashReport {
|
||||
const ReportesContent: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { hasRole, canSee, can, brand } = useAuth();
|
||||
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
|
||||
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg, propinas: propinasCfg } = useClinicSettings();
|
||||
const { t } = useI18n();
|
||||
const esAdmin = hasRole('admin');
|
||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||
@@ -169,8 +181,9 @@ const ReportesContent: FC = () => {
|
||||
if (k === 'membresias' && !membresiasCfg.activo) return false;
|
||||
if (k === 'recursos' && !recursosCfg.activo) return false;
|
||||
if ((k === 'compras' || k === 'margen') && !comprasCfg.activo) return false;
|
||||
if (k === 'propinas' && !propinasCfg.activo) return false;
|
||||
return canSee(`/reportes?tab=${k}`);
|
||||
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo]);
|
||||
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo, propinasCfg.activo]);
|
||||
const familiasVisibles = useMemo(
|
||||
() =>
|
||||
FAMILIAS
|
||||
@@ -510,6 +523,10 @@ const ReportesContent: FC = () => {
|
||||
<RecomendacionesReport />
|
||||
) : tab === 'kpis' ? (
|
||||
<KpisReport />
|
||||
) : tab === 'lealtad' ? (
|
||||
<LealtadReport />
|
||||
) : tab === 'propinas' ? (
|
||||
<PropinasReport />
|
||||
) : (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
|
||||
@@ -622,6 +622,12 @@ export interface Sale {
|
||||
/** Cobro: quién cobró y con qué método (módulo Tickets) */
|
||||
payment_method?: string;
|
||||
user_login?: string;
|
||||
/** Propina cobrada (0 si el módulo está apagado). */
|
||||
propina?: number;
|
||||
propina_moneda?: string;
|
||||
/** Descuento automático por nivel VIP. */
|
||||
nivel_discount?: number;
|
||||
nivel_codigo?: string;
|
||||
/** Pagos ligados al ticket (los nuevos; los antiguos no tienen vínculo) */
|
||||
pagos?: Payment[];
|
||||
}
|
||||
@@ -1087,6 +1093,8 @@ export interface ClinicSettings {
|
||||
recursos: RecursosSettings;
|
||||
compras: ComprasSettings;
|
||||
comisiones: ComisionesSettings;
|
||||
lealtad: LealtadSettings;
|
||||
propinas: PropinasSettings;
|
||||
licencia: LicenciaSettings;
|
||||
portal_cliente: PortalClienteSettings;
|
||||
}
|
||||
@@ -1567,6 +1575,156 @@ export const COMISIONES_DEFAULT: ComisionesSettings = {
|
||||
pago_minimo: 0,
|
||||
};
|
||||
|
||||
// ---- Lealtad (puntos del monedero y niveles VIP) ----
|
||||
export type LealtadModo = 'clasico' | 'configurable';
|
||||
export type LealtadAcumular = 'pagado' | 'vendido';
|
||||
export type LealtadRedondear = 'abajo' | 'normal';
|
||||
export type LealtadAplicar = 'servicios' | 'productos' | 'membresias' | 'paquetes';
|
||||
export type LealtadAlcance = 'servicio' | 'articulo' | 'categoria' | 'nivel' | 'sucursal' | 'rango_fechas' | 'dia_semana';
|
||||
|
||||
export interface NivelLealtad {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
gasto_minimo: number;
|
||||
ventana_meses: number;
|
||||
descuento_pct: number;
|
||||
factor_puntos: number;
|
||||
vigencia_meses: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MultiplicadorLealtad {
|
||||
nombre: string;
|
||||
alcance: LealtadAlcance;
|
||||
valor: string;
|
||||
factor: number;
|
||||
fecha_desde: string;
|
||||
fecha_hasta: string;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
export interface LealtadSettings {
|
||||
activo: boolean;
|
||||
modo: LealtadModo;
|
||||
puntos_por_unidad: number;
|
||||
valor_punto: number;
|
||||
moneda_base: string;
|
||||
canje_max_pct: number;
|
||||
expira_meses: number;
|
||||
acumular_sobre: LealtadAcumular;
|
||||
aplicar_a: LealtadAplicar[];
|
||||
redondear: LealtadRedondear;
|
||||
niveles: NivelLealtad[];
|
||||
multiplicadores: MultiplicadorLealtad[];
|
||||
}
|
||||
|
||||
export const LEALTAD_DEFAULT: LealtadSettings = {
|
||||
activo: true,
|
||||
modo: 'clasico',
|
||||
puntos_por_unidad: 0.1,
|
||||
valor_punto: 1,
|
||||
moneda_base: 'MXN',
|
||||
canje_max_pct: 50,
|
||||
expira_meses: 0,
|
||||
acumular_sobre: 'pagado',
|
||||
aplicar_a: ['servicios', 'productos', 'membresias', 'paquetes'],
|
||||
redondear: 'abajo',
|
||||
niveles: [],
|
||||
multiplicadores: [],
|
||||
};
|
||||
|
||||
export interface NivelPaciente {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
descuento_pct: number;
|
||||
factor_puntos: number;
|
||||
color?: string;
|
||||
desde?: string | null;
|
||||
manual?: boolean;
|
||||
revisar?: string | null;
|
||||
}
|
||||
|
||||
export interface LealtadPaciente {
|
||||
status: string;
|
||||
patient_id: number;
|
||||
patient: string;
|
||||
activo: boolean;
|
||||
modo: LealtadModo;
|
||||
valor_punto: number;
|
||||
puntos: number;
|
||||
equivalente_mxn: number;
|
||||
gasto_ventana: number;
|
||||
nivel: NivelPaciente;
|
||||
niveles: NivelLealtad[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ReporteLealtad {
|
||||
status: string;
|
||||
modo: LealtadModo;
|
||||
valor_punto: number;
|
||||
otorgados: number;
|
||||
canjeados: number;
|
||||
expirados: number;
|
||||
ajustes: number;
|
||||
depositos: number;
|
||||
by_type: Record<string, number>;
|
||||
puntos_activos: number;
|
||||
pasivo_mxn: number;
|
||||
cuentas: number;
|
||||
niveles: NivelLealtad[];
|
||||
conteo_niveles: Record<string, number>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---- Propinas en el punto de venta ----
|
||||
export type PropinasReparto = 'ninguno' | 'equitativo' | 'por_participacion';
|
||||
|
||||
export interface PropinasSettings {
|
||||
activo: boolean;
|
||||
sugerencias_pct: number[];
|
||||
permitir_personalizado: boolean;
|
||||
permitir_sin_propina: boolean;
|
||||
reparto: PropinasReparto;
|
||||
incluir_en_liquidacion_comisiones: boolean;
|
||||
cuenta_propinas: string;
|
||||
}
|
||||
|
||||
export const PROPINAS_DEFAULT: PropinasSettings = {
|
||||
activo: false,
|
||||
sugerencias_pct: [10, 15, 20],
|
||||
permitir_personalizado: true,
|
||||
permitir_sin_propina: true,
|
||||
reparto: 'ninguno',
|
||||
incluir_en_liquidacion_comisiones: false,
|
||||
cuenta_propinas: '',
|
||||
};
|
||||
|
||||
export interface PropinaRepartoFila {
|
||||
employee_id: number | null;
|
||||
empleado: string;
|
||||
monto: number;
|
||||
propinas: number;
|
||||
}
|
||||
|
||||
export interface ReportePropinas {
|
||||
status: string;
|
||||
start: string;
|
||||
end: string;
|
||||
activo: boolean;
|
||||
reparto: PropinasReparto;
|
||||
por_empleado: PropinaRepartoFila[];
|
||||
detalle: {
|
||||
venta_id: number; folio: string; date: string | null; empleado: string;
|
||||
employee_id: number | null; rol: string; monto: number; moneda: string; cuenta: string;
|
||||
}[];
|
||||
total: number;
|
||||
total_repartido: number;
|
||||
total_ventas: number;
|
||||
cuenta_propinas: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type AlcanceComision = 'global' | 'categoria' | 'servicio' | 'articulo' | 'membresia' | 'paquete';
|
||||
export type TipoComision = 'porcentaje' | 'monto_fijo' | 'escalonado';
|
||||
export type TipoBonoComision = 'fijo' | 'porcentaje';
|
||||
@@ -2424,6 +2582,8 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
recursos: RECURSOS_DEFAULT,
|
||||
compras: COMPRAS_DEFAULT,
|
||||
comisiones: COMISIONES_DEFAULT,
|
||||
lealtad: LEALTAD_DEFAULT,
|
||||
propinas: PROPINAS_DEFAULT,
|
||||
licencia: LICENCIA_DEFAULT,
|
||||
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
||||
};
|
||||
@@ -2595,10 +2755,13 @@ export interface PatientWallet {
|
||||
patient: string;
|
||||
wallet_id: number | null;
|
||||
points: number;
|
||||
valor_punto?: number;
|
||||
equivalent_mxn: number;
|
||||
equivalent_usd: number | null;
|
||||
rate: number | null;
|
||||
depositado: number;
|
||||
/** Nivel VIP del paciente (vacío si no hay niveles configurados). */
|
||||
nivel?: NivelPaciente | null;
|
||||
movimientos: WalletTransaction[];
|
||||
message?: string;
|
||||
}
|
||||
@@ -2850,6 +3013,13 @@ export interface PosCheckoutPayload {
|
||||
amount_received?: number;
|
||||
pay_with_points?: boolean;
|
||||
currency?: 'MXN' | 'USD';
|
||||
/** Cita de origen (para atribuir la propina al personal que atendió). */
|
||||
cita_id?: number;
|
||||
/** Propina: porcentaje sugerido sobre los servicios, o monto libre. */
|
||||
propina_pct?: number;
|
||||
propina?: number;
|
||||
propina_employee_id?: number;
|
||||
cobrador_employee_id?: number;
|
||||
}
|
||||
|
||||
export interface ImpuestosDesglose {
|
||||
@@ -2884,13 +3054,20 @@ export interface PosCheckoutResult {
|
||||
puntos_usados: number;
|
||||
puntos_ganados: number;
|
||||
wallet_points: number;
|
||||
/** Propina registrada (0 si el módulo está apagado). */
|
||||
propina?: number;
|
||||
propina_moneda?: string;
|
||||
/** Descuento automático por nivel VIP. */
|
||||
nivel_discount?: number;
|
||||
nivel?: NivelPaciente | null;
|
||||
reparto_propina?: { employee_id: number | null; empleado: string; rol: string; monto: number; moneda: string }[];
|
||||
stock_warnings?: string[];
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
citas: { total: number; by_state: Record<string, number> };
|
||||
ventas: { count: number; total: number; total_paid: number; total_due: number };
|
||||
ventas: { count: number; total: number; total_paid: number; total_due: number; total_propinas?: number; total_servicios?: number };
|
||||
pagos: { total: number; count: number; by_method: Record<string, number> };
|
||||
inventario: { total: number; by_type: Record<string, number> };
|
||||
}
|
||||
@@ -3120,6 +3297,37 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Lealtad: puntos y niveles ----
|
||||
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async recalcularLealtad(patientId: number): Promise<{ status: string; nivel: NivelPaciente; gasto_ventana: number; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad/recalcular`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async setNivelLealtad(patientId: number, codigo: string, revisar?: string): Promise<{ status: string; nivel: NivelPaciente; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`, { codigo, revisar });
|
||||
return data;
|
||||
},
|
||||
|
||||
async adjustPatientPoints(patientId: number, points: number, motivo: string): Promise<{ status: string; points: number; applied: number; equivalente_mxn: number; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/points/adjust`, { points, motivo });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getReportLealtad(start?: string, end?: string): Promise<ReporteLealtad> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/lealtad`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getReportPropinas(start?: string, end?: string): Promise<ReportePropinas> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/propinas`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Portal del cliente (personal: invitar, accesos, sesiones) ----
|
||||
async getPortalAccesos(): Promise<{ status: string; activo: boolean; accesos: PortalAcceso[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/accesos`);
|
||||
|
||||
Reference in New Issue
Block a user