523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
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;
|