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:
197
frontend/src/lib/ticketConfig.ts
Normal file
197
frontend/src/lib/ticketConfig.ts
Normal 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'),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user