Files
SKEEN-Proyecto/frontend/src/components/TicketDesigner.tsx

523 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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, sucursal: sucursalMuestra }} 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;