Punto 11: editor de diseno de tickets en Configuracion > Tickets

- Nuevo modelo skeen.ticket.config (AbstractModel) con layout, datos de clinica
  y logo en ir.config_parameter; _mezclar() hace que layouts viejos hereden las
  secciones nuevas; _validar() rechaza valores imposibles y textos largos.
- Endpoints GET/PUT /ticket_config, PUT /ticket_config/clinica,
  POST /ticket_config/reset (reset solo admin) con auditoria en skeen_config_audit.
- Los datos de la clinica pasan de localStorage (cada caja imprimia distinto)
  a configuracion compartida en el servidor.
- TicketDoc.tsx renderiza segun el diseno: ancho 58/80mm, fuente, tamano de letra,
  separador, mayusculas, orden de bloques y 29 secciones activables.
- TicketDesigner.tsx: editor completo con vista previa en vivo, reorden de bloques,
  textos personalizables y carga de logo.
- TicketPrint.tsx ahora es un wrapper de TicketDoc; ReciboPrint, EstadoCuentaPrint,
  RecetaPrint, CortePrint y ConteoPrint usan useClinicaPrint().
- _sale_to_dict expone telefono del paciente, medico que atendio y datos de paquete;
  pos_checkout devuelve recibido/cambio en MXN y USD para imprimirlos.
This commit is contained in:
2026-09-24 18:19:51 +00:00
parent e89bcdca3a
commit 72f12946c3
18 changed files with 1433 additions and 228 deletions

View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { odooApi } from '../services/odoo';
// Configuración del ticket (diseño + datos de la clínica + logo).
//
// Vive en el servidor (ir.config_parameter) para que todas las cajas impriman
// igual. Antes los datos de la clínica se guardaban en el localStorage de cada
// navegador (clave skeen_clinic_settings), así que cada equipo imprimía una
// dirección distinta. Este módulo cachea la configuración en memoria, la
// comparte con quien la pida y deja el localStorage como respaldo de arranque.
export interface TicketClinica {
nombre: string;
direccion: string;
telefono: string;
email: string;
sitio: string;
rfc: string;
sucursal: string;
tiene_logo: boolean;
}
export interface TicketLayout {
ancho: '58mm' | '80mm';
fuente: 'monoespaciada' | 'sans';
letra: 'compacta' | 'normal' | 'grande';
separador: 'guiones' | 'solida' | 'ninguna';
mayusculas: boolean;
bloques: string[];
secciones: Record<string, boolean>;
textos: { encabezado: string; pie: string; politica: string };
}
export interface TicketConfig {
layout: TicketLayout;
clinica: TicketClinica;
logo: string;
}
const CACHE_KEY = 'skeen_ticket_config';
// Respaldo por si el API no responde (sin sesión, red caída): el último config
// leído, o el formato histórico del ticket si nunca se leyó nada.
export const FALLBACK_CONFIG: TicketConfig = {
layout: {
ancho: '80mm',
fuente: 'monoespaciada',
letra: 'normal',
separador: 'guiones',
mayusculas: true,
bloques: ['encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie'],
secciones: {
nombre_clinica: true,
direccion: true,
telefono_clinica: true,
sucursal: true,
rfc: false,
mensaje_encabezado: true,
folio: true,
fecha: true,
cajero: true,
paciente: true,
telefono_paciente: false,
doctor: true,
conceptos: true,
detalle_conceptos: true,
subtotal: true,
descuento: true,
puntos_canjeados: true,
total: true,
total_usd: true,
metodo_pago: true,
recibido: true,
cambio: true,
pagado: true,
saldo_pendiente: true,
puntos_ganados: true,
monedero: true,
notas: true,
politica: false,
mensaje_pie: true,
},
textos: {
encabezado: '',
pie: '¡Gracias por su visita!',
politica: 'Conserve este ticket. No tiene validez fiscal.',
},
},
clinica: {
nombre: 'SKEEN Derma Experts',
direccion: 'Playas de Rosarito, Baja California',
telefono: '+52 (661) 100-2172',
email: 'hola@skeen.mx',
sitio: 'https://skeen.mx',
rfc: '',
sucursal: '',
tiene_logo: false,
},
logo: '',
};
const clonar = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
const leerLocal = (): TicketConfig | null => {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as TicketConfig;
if (!parsed?.layout?.secciones || !parsed?.clinica) return null;
return parsed;
} catch {
return null;
}
};
let actual: TicketConfig = leerLocal() || clonar(FALLBACK_CONFIG);
const oyentes = new Set<(c: TicketConfig) => void>();
let peticion: Promise<TicketConfig> | null = null;
let yaCargo = false;
const publicar = (c: TicketConfig) => {
actual = c;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch {
// localStorage lleno o bloqueado: seguimos en memoria
}
oyentes.forEach((f) => f(c));
};
/** Fuerza la recarga (tras guardar en Configuración). */
export const recargarTicketConfig = async (): Promise<TicketConfig> => {
peticion = null;
yaCargo = false;
return cargarTicketConfig();
};
export const cargarTicketConfig = (): Promise<TicketConfig> => {
if (peticion) return peticion;
if (yaCargo) return Promise.resolve(actual);
peticion = odooApi.getTicketConfig()
.then((res) => {
if (res.status === 'success' && res.layout && res.clinica) {
publicar({
layout: res.layout as TicketConfig['layout'],
clinica: res.clinica as TicketConfig['clinica'],
logo: res.logo || '',
});
}
return actual;
})
.catch(() => actual)
.finally(() => {
peticion = null;
yaCargo = true;
});
return peticion;
};
/** Config actual dentro de React; se refresca sola cuando cambia. */
export const useTicketConfig = (): TicketConfig => {
const [cfg, setCfg] = useState<TicketConfig>(actual);
useEffect(() => {
const f = (c: TicketConfig) => setCfg(clonar(c));
oyentes.add(f);
cargarTicketConfig().then((c) => setCfg(clonar(c)));
return () => {
oyentes.delete(f);
};
}, []);
return cfg;
};
/** Versión no-React para componentes que solo leen (impresión puntual). */
export const ticketConfigActual = (): TicketConfig => actual;
/**
* Datos de la clínica para los documentos impresos (ticket, recibo, estado de
* cuenta, receta, corte, conteo). Sale de la configuración del servidor; si
* nunca se configuró, cae al skeen_clinic_settings de este navegador y de ahí
* a los valores históricos de SKEEN.
*/
export const useClinicaPrint = () => {
const c = useTicketConfig().clinica;
const local = leerLocal()?.clinica;
const mix = (campo: keyof TicketClinica, porDefecto = '') =>
String(c[campo] || local?.[campo] || porDefecto || '');
return {
nombre: mix('nombre', 'SKEEN Derma Experts'),
direccion: mix('direccion', 'Playas de Rosarito, Baja California'),
telefono: mix('telefono'),
email: mix('email'),
sitio: mix('sitio'),
rfc: mix('rfc'),
sucursal: mix('sucursal'),
};
};