Punto 7: gerencia puede corregir tickets sin cancelarlos (lineas, precios, descuento y notas) con ajuste automatico de monedero e inventario

This commit is contained in:
2026-09-24 17:53:22 +00:00
parent dcf2c367f1
commit e89bcdca3a
7 changed files with 881 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ const MODULOS = [
{ value: 'agenda', label: 'Agenda' },
{ value: 'paquetes', label: 'Paquetes' },
{ value: 'monedero', label: 'Monedero' },
{ value: 'ventas', label: 'Tickets y cobros' },
{ value: 'clinica', label: 'Clínica' },
];
@@ -40,6 +41,9 @@ const AuditoriaPanel: FC = () => {
const [loading, setLoading] = useState(true);
const [modulo, setModulo] = useState('');
// Se reemplaza con la lista que devuelve el API (viene de la selección del
// modelo); MODULOS queda como fallback mientras el backend no la mande.
const [modulos, setModulos] = useState<{ value: string; label: string }[]>(MODULOS);
const [usuario, setUsuario] = useState('');
const [entidad, setEntidad] = useState('');
const [desde, setDesde] = useState('');
@@ -59,6 +63,9 @@ const AuditoriaPanel: FC = () => {
});
if (res.status === 'success') {
setRows(res.registros);
if (res.modulos?.length) {
setModulos([{ value: '', label: 'Todos los módulos' }, ...res.modulos]);
}
setTotal(res.total);
setTotalPages(res.total_pages || 1);
}
@@ -92,7 +99,7 @@ const AuditoriaPanel: FC = () => {
<div className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<Select
label="Módulo"
options={MODULOS}
options={modulos}
value={modulo}
onChange={(e) => setModulo(e.target.value)}
className="sm:max-w-[190px]"

View File

@@ -0,0 +1,353 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Trash2, AlertTriangle, Search } from 'lucide-react';
import { Button, Input, Select, TextArea, Modal, toast } from './ui';
import { odooApi, type Sale, type SaleEditLine } from '../services/odoo';
interface Catalogo {
servicios: { id: number; name: string; price: number }[];
items: { id: number; name: string; price: number; qty: number }[];
}
interface Fila {
/** id de la línea en Odoo; las nuevas van sin id */
id?: number;
service_id?: number | null;
item_id?: number | null;
paquete_id?: number | null;
description: string;
quantity: number;
price_unit: number;
prescribed_by_id?: number | null;
/** nombre del paquete: solo informativo, bloquea cantidad/precio */
paquete?: string;
}
const money = (v: number) =>
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
interface TicketEditorProps {
ticket: Sale;
catalogo: Catalogo;
doctores: { id: number; name: string }[];
onClose: () => void;
onGuardado: (sale: Sale) => void;
}
/**
* Corrección de un ticket ya emitido (gerencia / administrador).
*
* Edita conceptos, cantidades, precios, el médico que cuenta en comisiones y
* el descuento SIN cancelar el ticket: el backend recalcula los puntos del
* monedero y el inventario para que queden coherentes con el nuevo importe.
*/
export const TicketEditor: FC<TicketEditorProps> = ({
ticket,
catalogo,
doctores,
onClose,
onGuardado,
}) => {
const [filas, setFilas] = useState<Fila[]>(() =>
ticket.lines.map((l) => ({
id: l.id,
service_id: l.service_id || null,
item_id: l.item_id || null,
paquete_id: l.paquete_id || null,
description: l.description || l.service || l.item || '',
quantity: l.quantity,
price_unit: l.price_unit,
prescribed_by_id: l.prescribed_by_id || null,
paquete: l.paquete || undefined,
})),
);
const [descuento, setDescuento] = useState(String(ticket.discount ?? 0));
const [notas, setNotas] = useState(ticket.notes || '');
const [motivo, setMotivo] = useState('');
const [buscando, setBuscando] = useState('');
const [guardando, setGuardando] = useState(false);
const [error, setError] = useState('');
const opciones = useMemo(() => {
const q = buscando.trim().toLowerCase();
if (!q) return [];
const svcs = catalogo.servicios
.filter((s) => s.name.toLowerCase().includes(q))
.slice(0, 8)
.map((s) => ({ kind: 'servicio' as const, id: s.id, name: s.name, price: s.price }));
const its = catalogo.items
.filter((i) => i.name.toLowerCase().includes(q))
.slice(0, 8)
.map((i) => ({ kind: 'item' as const, id: i.id, name: i.name, price: i.price }));
return [...svcs, ...its];
}, [buscando, catalogo]);
const subtotal = filas.reduce((a, f) => a + f.quantity * f.price_unit, 0);
const desc = parseFloat(descuento) || 0;
const nuevoTotal = Math.max(0, subtotal - desc);
const dif = nuevoTotal - ticket.total;
const hayPaquete = filas.some((f) => f.paquete_id);
const setFila = (i: number, parche: Partial<Fila>) =>
setFilas((prev) => prev.map((f, j) => (j === i ? { ...f, ...parche } : f)));
const agregar = (op: (typeof opciones)[number]) => {
setFilas((prev) => [
...prev,
{
...(op.kind === 'servicio'
? { service_id: op.id, item_id: null }
: { item_id: op.id, service_id: null }),
description: op.name,
quantity: 1,
price_unit: op.price || 0,
prescribed_by_id: null,
},
]);
setBuscando('');
};
const quitar = (i: number) => setFilas((prev) => prev.filter((_, j) => j !== i));
const guardar = async () => {
setError('');
if (!motivo.trim()) {
setError('Escribe el motivo de la corrección (queda en la bitácora).');
return;
}
if (filas.length === 0) {
setError('El ticket debe conservar al menos un concepto.');
return;
}
if (filas.some((f) => f.quantity <= 0)) {
setError('Revisa las cantidades: ninguna puede ser cero.');
return;
}
const lines: SaleEditLine[] = filas.map((f) => ({
id: f.id,
service_id: f.service_id ?? null,
item_id: f.item_id ?? null,
paquete_id: f.paquete_id ?? null,
description: f.description,
quantity: f.quantity,
price_unit: f.price_unit,
prescribed_by_id: f.prescribed_by_id ?? null,
}));
try {
setGuardando(true);
const res = await odooApi.updateSale(ticket.id, {
motivo: motivo.trim(),
lines,
discount: desc,
notes: notas,
});
const r = res.result;
const extras: string[] = [];
if (r?.points_adjusted) {
extras.push(`${r.points_adjusted > 0 ? '+' : ''}${r.points_adjusted} pts en el monedero`);
}
if (r?.stock_moves) extras.push(`${r.stock_moves} ajuste(s) de inventario`);
toast.success(
`Ticket ${ticket.name} corregido · ${money(r?.prev_total ?? ticket.total)} → ${money(r?.total ?? nuevoTotal)}`
+ (extras.length ? ` · ${extras.join(' · ')}` : ''),
);
(r?.warnings || []).forEach((w) => toast.info(w, { duration: 9000 }));
if (res.sale) onGuardado(res.sale);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(msg || 'No se pudo guardar la corrección');
} finally {
setGuardando(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
title={`Corregir ticket ${ticket.name}`}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={guardando}>Descartar</Button>
<Button onClick={guardar} loading={guardando}>Guardar corrección</Button>
</>
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{ticket.patient}</span>
</span>
<span className="text-theme-muted">
Cobró: <span className="font-medium text-theme-heading">{ticket.user_login || '—'}</span>
</span>
<span className="text-theme-muted">
Pagado: <span className="font-medium text-theme-heading">{money(ticket.amount_paid)}</span>
</span>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
<div className="space-y-2">
{filas.map((f, i) => (
<div key={f.id ?? `nueva-${i}`} className="rounded-xl border border-theme-border p-2.5">
<div className="flex items-center gap-2">
<input
value={f.description}
onChange={(e) => setFila(i, { description: e.target.value })}
placeholder="Concepto"
className="flex-1 min-w-0 bg-transparent text-sm font-medium text-theme-heading focus:outline-none"
/>
{f.paquete && (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-theme-accent text-theme-inverse shrink-0">
Paquete · {f.paquete}
</span>
)}
<button
type="button"
onClick={() => quitar(i)}
disabled={!!f.paquete_id}
title={f.paquete_id
? 'Los paquetes se ajustan desde el módulo Paquetes'
: 'Quitar concepto'}
className="text-theme-muted hover:text-rose-600 disabled:opacity-30 shrink-0"
>
<Trash2 size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
<Input
label="Cant."
type="number"
min={0}
step="1"
value={String(f.quantity)}
disabled={!!f.paquete_id}
onChange={(e) => setFila(i, { quantity: parseFloat(e.target.value) || 0 })}
/>
<Input
label="Precio"
type="number"
min={0}
step="0.01"
value={String(f.price_unit)}
disabled={!!f.paquete_id}
onChange={(e) => setFila(i, { price_unit: parseFloat(e.target.value) || 0 })}
/>
<Select
label="Venta a nombre de"
value={String(f.prescribed_by_id ?? '')}
onChange={(e) => setFila(i, {
prescribed_by_id: e.target.value ? Number(e.target.value) : null,
})}
options={[
{ value: '', label: 'Sin asignar' },
...doctores.map((d) => ({ value: String(d.id), label: d.name })),
]}
/>
<div className="pt-6 text-right text-sm font-semibold text-theme-heading">
{money(f.quantity * f.price_unit)}
</div>
</div>
</div>
))}
</div>
<div className="relative mt-2">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Agregar concepto: busca un servicio o un artículo..."
value={buscando}
onChange={(e) => setBuscando(e.target.value)}
className="pl-9"
/>
{opciones.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-64 overflow-auto rounded-xl border border-theme-border bg-theme-surface shadow-lg">
{opciones.map((o) => (
<button
key={`${o.kind}-${o.id}`}
type="button"
onClick={() => agregar(o)}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg flex items-center justify-between gap-2"
>
<span className="text-theme-heading truncate">{o.name}</span>
<span className="text-theme-muted shrink-0 text-xs">
{o.kind === 'servicio' ? 'Servicio' : 'Artículo'} · {money(o.price)}
</span>
</button>
))}
</div>
)}
{buscando.trim() && opciones.length === 0 && (
<p className="mt-1 text-xs text-theme-muted">Sin coincidencias en el catálogo.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label="Descuento"
type="number"
min={0}
step="0.01"
value={descuento}
onChange={(e) => setDescuento(e.target.value)}
/>
<Input label="Notas del ticket" value={notas} onChange={(e) => setNotas(e.target.value)} />
</div>
<div className="rounded-xl bg-theme-bg p-3 space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Total actual</span>
<span className="text-theme-heading">{money(ticket.total)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Total corregido</span>
<span className="font-semibold text-theme-heading">{money(nuevoTotal)}</span>
</div>
{Math.abs(dif) > 0.005 && (
<div className="flex items-center justify-between text-xs">
<span className="text-theme-muted">Diferencia</span>
<span className={dif > 0 ? 'text-amber-700' : 'text-emerald-700'}>
{dif > 0 ? '+' : ''}{money(dif)}
</span>
</div>
)}
<p className="text-[11px] text-theme-muted pt-1 border-t border-theme-border">
Lo cobrado no cambia ({money(ticket.amount_paid)}). Si el nuevo total es mayor, el
ticket queda con saldo; si es menor, el dinero sobrante sigue en caja y hay que
devolverlo o dejarlo como saldo a favor. Los puntos del monedero y el inventario se
ajustan solos a la diferencia.
</p>
</div>
{hayPaquete && (
<div className="flex items-start gap-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Este ticket vendió paquetes: sus conceptos no se editan aquí. Si el paciente ya
consumió sesiones, primero ayústalo o anula el paquete en el módulo Paquetes.
</span>
</div>
)}
<TextArea
label="Motivo de la corrección *"
placeholder="Ej. recepción capturó mal el precio del tratamiento / faltó el cargo del material"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
/>
{error && (
<p className="text-sm text-rose-600 flex items-center gap-1.5">
<AlertTriangle size={14} /> {error}
</p>
)}
</div>
</Modal>
);
};
export default TicketEditor;

View File

@@ -1,11 +1,13 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User } from 'lucide-react';
import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User, Pencil } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui';
import TicketPrint from '../components/TicketPrint';
import TicketEditor from '../components/TicketEditor';
import { odooApi, type Sale } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
const PREF_KEY = 'skeen_tickets_filtros';
@@ -45,6 +47,8 @@ const PAGE_SIZE = 50;
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
*/
const Tickets: FC = () => {
const { user } = useAuth();
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
const prefs = readDateFilterPrefs(PREF_KEY);
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
@@ -64,6 +68,32 @@ const Tickets: FC = () => {
const [motivo, setMotivo] = useState('');
const [enviando, setEnviando] = useState(false);
const [reimprimir, setReimprimir] = useState<Sale | null>(null);
const [editando, setEditando] = useState<Sale | null>(null);
const [catalogo, setCatalogo] = useState<{
servicios: { id: number; name: string; price: number }[];
items: { id: number; name: string; price: number; qty: number }[];
}>({ servicios: [], items: [] });
const [doctores, setDoctores] = useState<{ id: number; name: string }[]>([]);
// El catálogo y los doctores solo hacen falta para corregir un ticket: se
// cargan perezosamente la primera vez que se abre el editor.
const cargarCatalogos = useCallback(async () => {
if (catalogo.servicios.length) return;
try {
const [sv, iv, dc] = await Promise.all([
odooApi.getServices({ page_size: 200 }),
odooApi.getInventory(),
odooApi.getDoctors(),
]);
setCatalogo({
servicios: (sv.services || []).map((x) => ({ id: x.id, name: x.name, price: x.price })),
items: (iv.items || []).map((x) => ({ id: x.id, name: x.name, price: x.price ?? 0, qty: x.qty })),
});
setDoctores((dc.doctors || []).map((x) => ({ id: x.id, name: x.name })));
} catch {
toast.error('No se pudo cargar el catálogo para corregir el ticket');
}
}, [catalogo.servicios.length]);
const aplicarPreset = (p: DatePreset) => {
setPreset(p);
@@ -264,6 +294,16 @@ const Tickets: FC = () => {
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
<Receipt size={14} />
</Button>
{s.state !== 'cancelled' && puedeCorregir && (
<Button
size="sm"
variant="ghost"
title="Corregir ticket"
onClick={async () => { await cargarCatalogos(); setEditando(s); }}
>
<Pencil size={14} />
</Button>
)}
{s.state !== 'cancelled' && (
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
<XCircle size={14} className="text-rose-600" />
@@ -305,6 +345,11 @@ const Tickets: FC = () => {
</Button>
)}
<Button variant="outline" onClick={() => setDetalle(null)}>Cerrar</Button>
{detalle && detalle.state !== 'cancelled' && puedeCorregir && (
<Button variant="secondary" onClick={async () => { const d = detalle; setDetalle(null); await cargarCatalogos(); setEditando(d); }}>
<Pencil size={14} className="mr-1.5" /> Corregir
</Button>
)}
{detalle && detalle.state !== 'cancelled' && (
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
@@ -440,6 +485,16 @@ const Tickets: FC = () => {
)}
</Modal>
{editando && (
<TicketEditor
ticket={editando}
catalogo={{ servicios: catalogo.servicios, items: catalogo.items }}
doctores={doctores}
onClose={() => setEditando(null)}
onGuardado={(sale) => { setDetalle(sale); load(); }}
/>
)}
<TicketPrint
isOpen={!!reimprimir}
onClose={() => setReimprimir(null)}

View File

@@ -365,6 +365,8 @@ export interface ConfigAuditResponse {
page: number;
page_size: number;
total_pages: number;
/** Módulos posibles según la selección del modelo (fallback: MODULOS local) */
modulos?: { value: string; label: string }[];
message?: string;
}
@@ -474,6 +476,33 @@ export interface SaleLine {
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
/** La línea vendió un paquete de sesiones: cantidad y precio no se editan
* libremente (el saldo del paciente depende de ellos). */
paquete_id?: number | null;
paquete?: string;
sesiones?: number;
}
/** Edición de un ticket ya emitido (gerencia): reemplaza líneas e importes. */
export interface SaleEditLine {
id?: number;
service_id?: number | null;
item_id?: number | null;
paquete_id?: number | null;
description: string;
quantity: number;
price_unit: number;
prescribed_by_id?: number | null;
}
export interface SaleEditResult {
prev_total: number;
total: number;
diff: number;
points_adjusted: number;
stock_moves: number;
packages_cancelled: number;
warnings: string[];
}
export interface CommissionRow {
@@ -1892,6 +1921,18 @@ export const odooApi = {
return data;
},
/** Corrige un ticket ya emitido sin cancelarlo (gerencia / admin). */
async updateSale(id: number, payload: {
motivo: string;
lines?: SaleEditLine[];
discount?: number;
notes?: string;
date?: string;
}): Promise<{ status: string; sale: Sale; result: SaleEditResult }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}`, payload);
return data;
},
// Última venta del paciente con precios actuales del catálogo (POS: repetir venta)
async getLastSale(patientId: number): Promise<LastSaleResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales/last`, { params: { patient_id: patientId } });