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:
518
frontend/src/components/TicketDesigner.tsx
Normal file
518
frontend/src/components/TicketDesigner.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, Image as ImageIcon, RotateCcw, Save, GripVertical } from 'lucide-react';
|
||||
import { Button, Card, Input, Select, TextArea, toast } from './ui';
|
||||
import TicketDoc, { type TicketData } from './TicketDoc';
|
||||
import {
|
||||
cargarTicketConfig,
|
||||
recargarTicketConfig,
|
||||
useTicketConfig,
|
||||
type TicketConfig,
|
||||
type TicketLayout,
|
||||
} from '../lib/ticketConfig';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
// Editor del diseño del ticket (Configuración › Tickets).
|
||||
//
|
||||
// Todo se ve reflejado en la vista previa en vivo: es un papel térmico, así que
|
||||
// la forma más segura de editar es ver el resultado mientras se mueve cada
|
||||
// opción. Al guardar se escribe en el servidor (ir.config_parameter) y las
|
||||
// demás cajas lo toman con la siguiente impresión.
|
||||
|
||||
const SECCIONES: { bloque: string; label: string; claves: { key: string; label: string }[] }[] = [
|
||||
{
|
||||
bloque: 'encabezado',
|
||||
label: 'Encabezado',
|
||||
claves: [
|
||||
{ key: 'nombre_clinica', label: 'Nombre de la clínica' },
|
||||
{ key: 'direccion', label: 'Dirección' },
|
||||
{ key: 'telefono_clinica', label: 'Teléfono' },
|
||||
{ key: 'sucursal', label: 'Sucursal' },
|
||||
{ key: 'rfc', label: 'RFC' },
|
||||
{ key: 'mensaje_encabezado', label: 'Mensaje de encabezado' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'cliente',
|
||||
label: 'Datos del paciente',
|
||||
claves: [
|
||||
{ key: 'folio', label: 'Folio' },
|
||||
{ key: 'fecha', label: 'Fecha' },
|
||||
{ key: 'paciente', label: 'Paciente' },
|
||||
{ key: 'telefono_paciente', label: 'Teléfono del paciente' },
|
||||
{ key: 'doctor', label: 'Doctor que atendió' },
|
||||
{ key: 'cajero', label: 'Quién cobró' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'conceptos',
|
||||
label: 'Conceptos',
|
||||
claves: [
|
||||
{ key: 'conceptos', label: 'Lista de conceptos' },
|
||||
{ key: 'detalle_conceptos', label: 'Cantidad × precio en cada línea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'totales',
|
||||
label: 'Totales',
|
||||
claves: [
|
||||
{ key: 'subtotal', label: 'Subtotal' },
|
||||
{ key: 'descuento', label: 'Descuento' },
|
||||
{ key: 'puntos_canjeados', label: 'Puntos canjeados' },
|
||||
{ key: 'total', label: 'Total' },
|
||||
{ key: 'total_usd', label: 'Equivalente en dólares' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'pago',
|
||||
label: 'Pago',
|
||||
claves: [
|
||||
{ key: 'metodo_pago', label: 'Método de pago' },
|
||||
{ key: 'recibido', label: 'Recibido' },
|
||||
{ key: 'cambio', label: 'Cambio' },
|
||||
{ key: 'pagado', label: 'Pagado' },
|
||||
{ key: 'saldo_pendiente', label: 'Saldo pendiente' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'monedero',
|
||||
label: 'Monedero',
|
||||
claves: [
|
||||
{ key: 'puntos_ganados', label: 'Puntos ganados' },
|
||||
{ key: 'monedero', label: 'Saldo del monedero' },
|
||||
],
|
||||
},
|
||||
{
|
||||
bloque: 'pie',
|
||||
label: 'Pie',
|
||||
claves: [
|
||||
{ key: 'notas', label: 'Notas del ticket' },
|
||||
{ key: 'politica', label: 'Aviso de validez fiscal' },
|
||||
{ key: 'mensaje_pie', label: 'Mensaje de despedida' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const MUESTRA: TicketData = {
|
||||
folio: 'VENTA-00123',
|
||||
fecha: '24/09/2026 12:35',
|
||||
paciente: 'María Fernanda Ruiz',
|
||||
telefono_paciente: '661 112 3456',
|
||||
doctor: 'Dra. Aranza Beltrán',
|
||||
cajero: 'recepcion',
|
||||
lines: [
|
||||
{ name: 'Limpieza facial profunda', qty: 1, price: 1250, subtotal: 1250 },
|
||||
{ name: 'Peel químico', qty: 1, price: 1500, subtotal: 1500 },
|
||||
{ name: 'Pantalla mineral SPF 50', qty: 2, price: 385, subtotal: 770 },
|
||||
],
|
||||
subtotal: 3520,
|
||||
discount: 320,
|
||||
total: 3200,
|
||||
metodo: 'Mixto',
|
||||
recibido: 3500,
|
||||
cambio: 300,
|
||||
amount_paid: 3200,
|
||||
amount_due: 0,
|
||||
puntos_usados: 200,
|
||||
puntos_ganados: 320,
|
||||
wallet_points: 1480,
|
||||
rate_used: 17.25,
|
||||
notas: 'Segunda sesión de peel en 4 semanas.',
|
||||
sucursal: 'Rosarito Centro',
|
||||
};
|
||||
|
||||
const CLINICA_CAMPOS: { key: string; label: string; placeholder?: string }[] = [
|
||||
{ key: 'nombre', label: 'Nombre de la clínica' },
|
||||
{ key: 'direccion', label: 'Dirección' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ key: 'email', label: 'Correo' },
|
||||
{ key: 'sitio', label: 'Sitio web' },
|
||||
{ key: 'rfc', label: 'RFC (opcional)' },
|
||||
{ key: 'sucursal', label: 'Sucursal (opcional)' },
|
||||
];
|
||||
|
||||
export const TicketDesigner: FC = () => {
|
||||
const config = useTicketConfig();
|
||||
const { user } = useAuth();
|
||||
// Restaurar el formato original queda solo para administración del sistema;
|
||||
// gerencia puede editar y guardar su propio diseño.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
const [draft, setDraft] = useState<TicketConfig>(config);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [nota, setNota] = useState('');
|
||||
const [logoError, setLogoError] = useState('');
|
||||
const archivoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Al terminar de cargar del servidor, sincroniza el borrador (la primera
|
||||
// renderización puede venir del cache local).
|
||||
useEffect(() => {
|
||||
cargarTicketConfig().then((c) => setDraft(c));
|
||||
}, []);
|
||||
|
||||
const sucio = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(config),
|
||||
[draft, config],
|
||||
);
|
||||
|
||||
const setLayout = (parche: Partial<TicketLayout>) =>
|
||||
setDraft((d) => ({ ...d, layout: { ...d.layout, ...parche } }));
|
||||
|
||||
const toggleSeccion = (key: string) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
layout: {
|
||||
...d.layout,
|
||||
secciones: { ...d.layout.secciones, [key]: d.layout.secciones[key] === false },
|
||||
},
|
||||
}));
|
||||
|
||||
const setTexto = (key: keyof TicketLayout['textos'], valor: string) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
layout: { ...d.layout, textos: { ...d.layout.textos, [key]: valor } },
|
||||
}));
|
||||
|
||||
const setClinica = (key: string, valor: string) =>
|
||||
setDraft((d) => ({ ...d, clinica: { ...d.clinica, [key]: valor } }));
|
||||
|
||||
const moverBloque = (i: number, dir: -1 | 1) =>
|
||||
setDraft((d) => {
|
||||
const orden = [...d.layout.bloques];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= orden.length) return d;
|
||||
[orden[i], orden[j]] = [orden[j], orden[i]];
|
||||
return { ...d, layout: { ...d.layout, bloques: orden } };
|
||||
});
|
||||
|
||||
const elegirLogo = (file?: File) => {
|
||||
setLogoError('');
|
||||
if (!file) return;
|
||||
if (file.size > 250 * 1024) {
|
||||
setLogoError('La imagen pesa más de 250 KB. Use una versión pequeña.');
|
||||
return;
|
||||
}
|
||||
const lector = new FileReader();
|
||||
lector.onload = () => {
|
||||
const url = String(lector.result || '');
|
||||
if (!url.startsWith('data:image/')) {
|
||||
setLogoError('Solo se admiten imágenes (PNG, JPG o SVG).');
|
||||
return;
|
||||
}
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
logo: url,
|
||||
clinica: { ...d.clinica, tiene_logo: true },
|
||||
}));
|
||||
};
|
||||
lector.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const guardar = async () => {
|
||||
try {
|
||||
setGuardando(true);
|
||||
const motivo = nota.trim() || 'actualizado desde Configuración';
|
||||
const resLay = await odooApi.putTicketConfig(draft.layout, motivo);
|
||||
if (resLay.status !== 'success') {
|
||||
toast.error(resLay.message || 'No se pudo guardar el diseño');
|
||||
return;
|
||||
}
|
||||
const clinica: Record<string, string> = {};
|
||||
CLINICA_CAMPOS.forEach((c) => {
|
||||
clinica[c.key] = String(draft.clinica[c.key as keyof typeof draft.clinica] ?? '');
|
||||
});
|
||||
const resCli = await odooApi.putTicketClinica({
|
||||
clinica,
|
||||
logo: draft.clinica.tiene_logo ? draft.logo : '',
|
||||
motivo,
|
||||
});
|
||||
if (resCli.status !== 'success') {
|
||||
toast.error(resCli.message || 'No se pudieron guardar los datos de la clínica');
|
||||
return;
|
||||
}
|
||||
await recargarTicketConfig();
|
||||
setNota('');
|
||||
toast.success('Diseño del ticket guardado. Las cajas lo toman al imprimir.');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar el diseño');
|
||||
} finally {
|
||||
setGuardando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restaurar = async () => {
|
||||
if (!window.confirm('¿Restaurar el diseño original del ticket? Se pierden los cambios guardados.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGuardando(true);
|
||||
const res = await odooApi.resetTicketConfig();
|
||||
if (res.status !== 'success') {
|
||||
toast.error(res.message || 'No se pudo restaurar');
|
||||
return;
|
||||
}
|
||||
const nuevo = await recargarTicketConfig();
|
||||
setDraft(nuevo);
|
||||
toast.success('Diseño restaurado al formato original');
|
||||
} catch {
|
||||
toast.error('No se pudo restaurar el diseño');
|
||||
} finally {
|
||||
setGuardando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewConfig: TicketConfig = draft;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-4 items-start">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<Card.Body className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold text-theme-heading">Formato del papel</h3>
|
||||
<p className="text-xs text-theme-muted mt-0.5">
|
||||
Ancho de la térmica, tipografía y densidad del texto.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Select
|
||||
label="Ancho"
|
||||
value={draft.layout.ancho}
|
||||
onChange={(e) => setLayout({ ancho: e.target.value as TicketLayout['ancho'] })}
|
||||
options={[
|
||||
{ value: '80mm', label: '80 mm (estándar)' },
|
||||
{ value: '58mm', label: '58 mm (estrecha)' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Fuente"
|
||||
value={draft.layout.fuente}
|
||||
onChange={(e) => setLayout({ fuente: e.target.value as TicketLayout['fuente'] })}
|
||||
options={[
|
||||
{ value: 'monoespaciada', label: 'Monoespaciada' },
|
||||
{ value: 'sans', label: 'Ariel / sans' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Tamaño de letra"
|
||||
value={draft.layout.letra}
|
||||
onChange={(e) => setLayout({ letra: e.target.value as TicketLayout['letra'] })}
|
||||
options={[
|
||||
{ value: 'compacta', label: 'Compacta (cabe más)' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'grande', label: 'Grande (más legible)' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Separadores"
|
||||
value={draft.layout.separador}
|
||||
onChange={(e) => setLayout({ separador: e.target.value as TicketLayout['separador'] })}
|
||||
options={[
|
||||
{ value: 'guiones', label: 'Guiones (---)' },
|
||||
{ value: 'solida', label: 'Línea sólida' },
|
||||
{ value: 'ninguna', label: 'Sin línea' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-theme-border-strong"
|
||||
checked={draft.layout.mayusculas}
|
||||
onChange={(e) => setLayout({ mayusculas: e.target.checked })}
|
||||
/>
|
||||
Escribir todo en MAYÚSCULAS
|
||||
</label>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold text-theme-heading">Rubros del ticket</h3>
|
||||
<p className="text-xs text-theme-muted mt-0.5">
|
||||
Marque lo que debe salir impreso. Use las flechas para cambiar el orden de las secciones.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...SECCIONES].sort(
|
||||
(a, b) => draft.layout.bloques.indexOf(a.bloque) - draft.layout.bloques.indexOf(b.bloque),
|
||||
).map((grupo) => {
|
||||
const i = draft.layout.bloques.indexOf(grupo.bloque);
|
||||
return (
|
||||
<div key={grupo.bloque} className="rounded-xl border border-theme-border p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-theme-heading flex items-center gap-1.5">
|
||||
<GripVertical size={13} className="text-theme-muted" />
|
||||
{grupo.label}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Subir sección"
|
||||
disabled={i <= 0}
|
||||
onClick={() => moverBloque(i, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Bajar sección"
|
||||
disabled={i >= draft.layout.bloques.length - 1}
|
||||
onClick={() => moverBloque(i, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-1.5">
|
||||
{grupo.claves.map((c) => (
|
||||
<label
|
||||
key={c.key}
|
||||
className="flex items-center gap-2 text-[13px] text-theme-muted cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-theme-border-strong"
|
||||
checked={draft.layout.secciones[c.key] !== false}
|
||||
onChange={() => toggleSeccion(c.key)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold text-theme-heading">Textos impresos</h3>
|
||||
<p className="text-xs text-theme-muted mt-0.5">
|
||||
Mensajes que salen en el ticket además de los datos de la venta.
|
||||
</p>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Mensaje de encabezado"
|
||||
rows={2}
|
||||
maxLength={240}
|
||||
placeholder="Ej. Consulta dermatológica y estética"
|
||||
value={draft.layout.textos.encabezado}
|
||||
onChange={(e) => setTexto('encabezado', e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Mensaje de despedida (pie)"
|
||||
rows={2}
|
||||
maxLength={240}
|
||||
value={draft.layout.textos.pie}
|
||||
onChange={(e) => setTexto('pie', e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Aviso de validez / política"
|
||||
rows={2}
|
||||
maxLength={240}
|
||||
value={draft.layout.textos.politica}
|
||||
onChange={(e) => setTexto('politica', e.target.value)}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold text-theme-heading">Datos de la clínica</h3>
|
||||
<p className="text-xs text-theme-muted mt-0.5">
|
||||
Antes se guardaban en cada computadora; ahora son compartidos para todas las cajas.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{CLINICA_CAMPOS.map((c) => (
|
||||
<Input
|
||||
key={c.key}
|
||||
label={c.label}
|
||||
value={String(draft.clinica[c.key as keyof typeof draft.clinica] ?? '')}
|
||||
maxLength={160}
|
||||
onChange={(e) => setClinica(c.key, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<input
|
||||
ref={archivoRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(e) => elegirLogo(e.target.files?.[0])}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => archivoRef.current?.click()}>
|
||||
<ImageIcon size={14} className="mr-1.5" /> Cambiar logo
|
||||
</Button>
|
||||
{draft.clinica.tiene_logo && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDraft((d) => ({
|
||||
...d,
|
||||
logo: '',
|
||||
clinica: { ...d.clinica, tiene_logo: false },
|
||||
}))}
|
||||
>
|
||||
Quitar logo
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-theme-muted">PNG o JPG, máximo 250 KB</span>
|
||||
</div>
|
||||
{logoError && <p className="text-xs text-rose-600">{logoError}</p>}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body className="space-y-3">
|
||||
<Input
|
||||
label="Nota del cambio (para la bitácora)"
|
||||
placeholder="Ej. se quitó el RFC porque las cajas de 58 mm lo cortaban"
|
||||
value={nota}
|
||||
onChange={(e) => setNota(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={guardar} loading={guardando} disabled={!sucio}>
|
||||
<Save size={15} className="mr-2" /> Guardar diseño
|
||||
</Button>
|
||||
{esAdmin && (
|
||||
<Button variant="outline" onClick={restaurar} disabled={guardando}>
|
||||
<RotateCcw size={15} className="mr-2" /> Restaurar original
|
||||
</Button>
|
||||
)}
|
||||
{sucio && (
|
||||
<span className="text-xs text-amber-700">
|
||||
Hay cambios sin guardar.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="xl:sticky xl:top-4">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
|
||||
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
|
||||
<TicketDoc data={MUESTRA} config={previewConfig} preview />
|
||||
</div>
|
||||
<p className="text-[11px] text-theme-muted mt-2">
|
||||
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman
|
||||
de la venta al imprimir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketDesigner;
|
||||
Reference in New Issue
Block a user