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'; import { useSucursales } from '../lib/sucursales'; // 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(); const { sucursales } = useSucursales(); // Nombre de la sucursal predeterminada para la vista previa (antes fijo). const sucursalMuestra = (sucursales.find((s) => s.is_default) || sucursales[0])?.name || 'Rosarito Centro'; // 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(config); const [guardando, setGuardando] = useState(false); const [nota, setNota] = useState(''); const [logoError, setLogoError] = useState(''); const archivoRef = useRef(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) => 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 = {}; 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 (

Formato del papel

Ancho de la térmica, tipografía y densidad del texto.

setLayout({ fuente: e.target.value as TicketLayout['fuente'] })} options={[ { value: 'monoespaciada', label: 'Monoespaciada' }, { value: 'sans', label: 'Ariel / sans' }, ]} /> 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' }, ]} />

Rubros del ticket

Marque lo que debe salir impreso. Use las flechas para cambiar el orden de las secciones.

{[...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 (

{grupo.label}

{grupo.claves.map((c) => ( ))}
); })}

Textos impresos

Mensajes que salen en el ticket además de los datos de la venta.