Capítulo 04 · Aplicaciones
Escritorio: React + Tauri
La app de escritorio es el centro de mando del restaurante: el mapa con los repartidores en vivo, la pantalla de cocina, las gráficas de ventas y el acceso al hardware — la impresora térmica y la red local. Tauri le da a tu React superpoderes de sistema operativo.
4.1 Qué es Tauri y cómo piensa
Tauri empaqueta tu aplicación web (React) como una app de escritorio nativa. Tiene dos procesos que conviven:
- El frontend: tu app React corriendo dentro del webview del sistema operativo (WebView2 en Windows). Aquí vive toda la interfaz.
- El core: un binario en Rust con acceso total al sistema — archivos, sockets TCP, USB, procesos. Aquí vive todo lo que un navegador no puede hacer.
Se comunican por IPC (comunicación entre procesos): desde React llamas funciones Rust marcadas como commands usando invoke(), y recibes la respuesta como una promesa. Este puente es la idea más importante del capítulo — la impresión térmica y el descubrimiento de red pasan por él:
// Un "command" es una función Rust expuesta al frontend.
#[tauri::command]
fn saludar(nombre: String) -> String {
format!("¡Hola, {}! Saludos desde Rust", nombre)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// registra aquí cada command que declares:
.invoke_handler(tauri::generate_handler![saludar])
.run(tauri::generate_context!())
.expect("error al iniciar la app");
}
import { invoke } from "@tauri-apps/api/core";
// invoke("nombre_del_command", { argumentos }) devuelve una promesa.
// Los argumentos viajan serializados como JSON (¡otra vez DTOs!).
const mensaje = await invoke<string>("saludar", { nombre: "Ana" });
| Tauri | Electron (la alternativa) | |
|---|---|---|
| Motor | Webview del sistema (WebView2) | Chromium completo embebido |
| Tamaño del instalador | ~5–15 MB | ~80–150 MB |
| RAM | Baja | Alta (un Chrome entero) |
| Lado nativo | Rust | Node.js |
| Por qué lo elegimos | Ligero para una PC de mostrador, y Rust habla TCP/USB sin capas intermedias — perfecto para la impresora térmica. | |
Para crear el proyecto (necesitas Node y Rust instalados; en Windows también WebView2, que ya viene con Windows 11):
npm create tauri-app@latest restaurante-escritorio
# elige: TypeScript / React / npm
cd restaurante-escritorio
npm install
npm run tauri dev # abre la ventana con recarga en vivo
No todo necesita un command. Tu React puede seguir usando fetch y WebSocket normales contra el backend (http://192.168.1.50:8080): eso no requiere Rust. Reserva los commands para lo que el webview no puede hacer: abrir sockets crudos (impresora), leer la IP local, tocar archivos. Regla práctica: si funcionaría en Chrome, no necesita command.
4.2 Mapa con repartidores en tiempo real
Objetivo: un mapa donde el administrador ve moverse a cada repartidor. Las piezas:
- El mapa:
leaflet+react-leaflet, la combinación libre y ligera estándar. Los "tiles" (las imágenes del mapa) vienen de OpenStreetMap. - Los datos: el teléfono del repartidor envía su posición por WebSocket al backend en la nube (capítulo 5.3), la nube la baja al backend local por el túnel (capítulo 3.7) y el hub local la difunde por el canal
/ws/ubicaciones(capítulo 3.3), donde este componente la escucha.
Gracias al túnel, al escritorio no le importa la arquitectura híbrida: se conecta a su backend local como para todo lo demás, y las ubicaciones simplemente aparecen en el canal, vengan de donde vengan. Este es el beneficio de que las apps solo conozcan a su backend.
npm install leaflet react-leaflet
npm install -D @types/leaflet
Primero, un hook que encapsula la conexión WebSocket con reconexión automática — lo reutilizarás para cocina y notificaciones:
import { useEffect, useRef, useState } from "react";
/** Se conecta a un canal WS del backend y entrega cada mensaje parseado.
* Reconecta solo, con espera creciente (1s, 2s, 4s… máx 15s). */
export function useCanal(canal: string, onMensaje: (m: any) => void) {
const [conectado, setConectado] = useState(false);
const intentos = useRef(0);
const cbRef = useRef(onMensaje);
cbRef.current = onMensaje; // siempre la versión más reciente del callback
useEffect(() => {
let ws: WebSocket | null = null;
let timer: number;
let cerrado = false;
function conectar() {
const token = localStorage.getItem("token");
ws = new WebSocket(`ws://192.168.1.50:8080/ws/${canal}?token=${token}`);
ws.onopen = () => { intentos.current = 0; setConectado(true); };
ws.onmessage = (ev) => cbRef.current(JSON.parse(ev.data));
ws.onclose = () => {
setConectado(false);
if (cerrado) return;
// backoff exponencial: no martillar al servidor caído
const espera = Math.min(1000 * 2 ** intentos.current, 15000);
intentos.current++;
timer = window.setTimeout(conectar, espera);
};
}
conectar();
return () => { cerrado = true; clearTimeout(timer); ws?.close(); };
}, [canal]);
return conectado;
}
Y el mapa. La posición de cada repartidor vive en un objeto indexado por ID, así el mensaje número mil solo actualiza un marcador en vez de acumular datos:
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import { divIcon } from "leaflet";
import "leaflet/dist/leaflet.css"; // ¡sin este import el mapa sale roto!
import { useState } from "react";
import { useCanal } from "../../hooks/useCanal";
interface Ubicacion {
repartidorId: number;
nombre: string;
lat: number;
lng: number;
ts: string;
}
// divIcon evita el clásico bug del ícono por defecto de Leaflet
// (sus PNGs no sobreviven al bundler) y de paso lo estilizamos:
const iconoRepartidor = divIcon({
className: "marcador-repartidor", // estílalo en tu CSS
html: "🛵",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
export function MapaRepartos() {
// Record<id, Ubicacion>: la clave hace que actualizar sea O(1)
const [posiciones, setPosiciones] = useState<Record<number, Ubicacion>>({});
const conectado = useCanal("ubicaciones", (msg) => {
if (msg.type !== "UBICACION") return;
const u: Ubicacion = msg.datos;
setPosiciones((prev) => ({ ...prev, [u.repartidorId]: u }));
});
return (
<div style={{ height: "100%", position: "relative" }}>
{!conectado && <div className="aviso-desconectado">Reconectando…</div>}
<MapContainer center={[19.7008, -101.1844]} zoom={14}
style={{ height: "100%" }}>
<TileLayer
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
/>
{Object.values(posiciones).map((u) => (
<Marker key={u.repartidorId} position={[u.lat, u.lng]}
icon={iconoRepartidor}>
<Popup>
{u.nombre} — visto {new Date(u.ts).toLocaleTimeString()}
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
Los tiles de OpenStreetMap requieren internet y su servidor público es para uso moderado (perfecto para un restaurante; prohibido para apps masivas). Si necesitas que el mapa funcione sin internet o a gran escala: (a) usa un proveedor de tiles con plan (MapTiler, Stamen), o (b) sirve tus propios tiles de tu ciudad con protomaps/tileserver-gl y la librería maplibre-gl — misma idea, tiles vectoriales locales.
Frecuencia de actualización: el teléfono no debe mandar su posición cada segundo (batería, datos). Cada 10–15 metros de movimiento o cada 10 segundos es más que suficiente para un mapa de repartos; el marcador puede animarse con una transición CSS para verse fluido entre actualizaciones.
4.3 Imprimir tickets: el lenguaje ESC/POS
Las impresoras térmicas de recibos (Epson TM-T20, Xprinter, 3nStar…) no entienden PDF ni HTML: entienden ESC/POS, un lenguaje de bytes creado por Epson que hoy es el estándar de facto. Un "documento" ESC/POS es simplemente texto plano intercalado con comandos de control:
| Comando | Bytes (hex) | Efecto |
|---|---|---|
ESC @ | 1B 40 | Inicializar impresora (empieza siempre con esto) |
ESC a n | 1B 61 n | Alineación: 0 izquierda, 1 centro, 2 derecha |
ESC E n | 1B 45 n | Negrita: 1 activa, 0 desactiva |
GS ! n | 1D 21 n | Tamaño: 0x00 normal, 0x11 doble alto y ancho |
ESC t n | 1B 74 n | Página de códigos (acentos): 19 = CP858 con á é í ñ € |
LF | 0A | Imprimir línea y avanzar papel |
GS V 66 n | 1D 56 42 n | Alimentar n líneas y corte parcial del papel |
ESC p 0 … | 1B 70 30 … | Abrir el cajón de dinero conectado a la impresora |
Dos datos clave del papel: una impresora de 80 mm imprime 42–48 columnas de texto normal; una de 58 mm, 32. Todo el arte de un ticket bonito es alinear texto con espacios sobre esa cuadrícula fija — por eso los tickets usan fuente monoespaciada:
Las tres vías para llegar a la impresora
| Vía | Cómo | Cuándo usarla |
|---|---|---|
| 1. Red (TCP 9100) | Socket a la IP de la impresora, escribes los bytes | Impresoras Ethernet/WiFi. La más simple y robusta. Recomendada. |
| 2. Vía backend | POST /api/pedidos/{id}/ticket → la cola del servidor imprime (cap. 3.4) | Cuando varios dispositivos comparten impresoras. La que usa el sistema completo. |
| 3. USB local | Rust escribe al dispositivo USB (crate escpos) | Impresora USB conectada directo a la PC de caja, sin red. |
Aunque en producción imprimirás vía backend, implementar la vía 1 en Tauri te enseña exactamente qué pasa en el cable. Armamos los bytes en Rust y los mandamos por TCP — sin ninguna librería, para que veas que no hay magia:
use std::io::Write;
use std::net::TcpStream;
use std::time::Duration;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct LineaTicket {
pub izquierda: String,
pub derecha: String,
}
const ANCHO: usize = 42; // impresora de 80 mm
/// Alinea texto-izquierda y texto-derecha rellenando con espacios.
fn fila(izq: &str, der: &str) -> String {
let espacios = ANCHO.saturating_sub(izq.chars().count() + der.chars().count());
format!("{}{}{}\n", izq, " ".repeat(espacios.max(1)), der)
}
/// Convierte texto a CP858 (la página de códigos con acentos y ñ).
/// Con el crate `codepage-437` o el crate `oem-cp`; aquí, versión simple:
fn a_cp858(texto: &str) -> Vec<u8> {
texto.chars().map(|c| match c {
'á' => 0xA0, 'é' => 0x82, 'í' => 0xA1, 'ó' => 0xA2, 'ú' => 0xA3,
'ñ' => 0xA4, 'Ñ' => 0xA5, '¡' => 0xAD, '¿' => 0xA8,
c if c.is_ascii() => c as u8,
_ => b'?',
}).collect()
}
#[tauri::command]
pub fn imprimir_ticket(
ip: String,
titulo: String,
lineas: Vec<LineaTicket>,
total: String,
) -> Result<(), String> {
let mut datos: Vec<u8> = Vec::new();
datos.extend([0x1B, 0x40]); // ESC @ inicializar
datos.extend([0x1B, 0x74, 19]); // ESC t 19 página CP858
datos.extend([0x1B, 0x61, 1]); // centrar
datos.extend([0x1D, 0x21, 0x11]); // doble tamaño
datos.extend(a_cp858(&titulo));
datos.push(0x0A);
datos.extend([0x1D, 0x21, 0x00]); // tamaño normal
datos.extend([0x1B, 0x61, 0]); // alinear izquierda
datos.extend(a_cp858(&"-".repeat(ANCHO)));
datos.push(0x0A);
for l in &lineas {
datos.extend(a_cp858(&fila(&l.izquierda, &l.derecha)));
}
datos.extend(a_cp858(&"-".repeat(ANCHO)));
datos.push(0x0A);
datos.extend([0x1B, 0x45, 1]); // negrita ON
datos.extend(a_cp858(&fila("TOTAL", &total)));
datos.extend([0x1B, 0x45, 0]); // negrita OFF
datos.extend([0x1D, 0x56, 66, 3]); // avanzar 3 líneas y cortar
// conectar y enviar (puerto RAW estándar: 9100)
let mut socket = TcpStream::connect((ip.as_str(), 9100))
.map_err(|e| format!("no se pudo conectar a la impresora: {e}"))?;
socket.set_write_timeout(Some(Duration::from_secs(5))).ok();
socket.write_all(&datos)
.map_err(|e| format!("error al enviar a la impresora: {e}"))?;
Ok(())
}
import { invoke } from "@tauri-apps/api/core";
import type { PedidoResponse } from "../../types/pedido";
export async function imprimirTicketLocal(pedido: PedidoResponse) {
await invoke("imprimir_ticket", {
ip: "192.168.1.61",
titulo: "LA COMANDA",
lineas: pedido.items.map((i) => ({
izquierda: `${i.cantidad}x ${i.nombre}`,
derecha: `$${(i.precio * i.cantidad).toFixed(2)}`,
})),
total: `$${pedido.total.toFixed(2)}`,
});
}
Recuerda registrar el command en el builder: tauri::generate_handler![saludar, impresora::imprimir_ticket]. Si prefieres no armar bytes a mano, el crate escpos de Rust y la librería node-thermal-printer (si imprimieras desde Node) ofrecen APIs de alto nivel — pero ahora ya sabes leer lo que generan.
Prueba los acentos primero. El error número uno con impresoras térmicas es imprimir ñ en lugar de ñ: pasa cuando mandas UTF-8 pero la impresora está en una página de códigos de un byte. Las dos soluciones: convertir el texto a CP858 como arriba, o transliterar (ñ→n, á→a) si tu impresora resulta no soportar esa página. Imprime un ticket de prueba con "ñáéíóú ¿¡" el primer día.
4.4 Recibir información de un teléfono en la misma red
"Que el teléfono le mande datos a la app de escritorio" tiene dos arquitecturas posibles, y conviene entender ambas:
A · A través del backend (recomendada)
- El teléfono envía al backend (REST/WS); el backend difunde por
/ws/notificaciones; el escritorio escucha - Cero configuración en los clientes: ambos ya conocen al servidor
- Funciona aunque el teléfono esté en 4G, fuera del local
- Es la arquitectura de todo el sistema (cap. 0.1)
B · Directa (P2P en la LAN)
- La app Tauri levanta un mini servidor HTTP; el teléfono le hace POST directo a
http://IP-de-la-PC:7420 - No necesita backend: útil para una herramienta chica o para emparejar dispositivos
- Requiere descubrimiento: ¿cómo sabe el teléfono la IP de la PC?
- Solo funciona dentro de la misma red
La vía A ya está construida: es literalmente el hub del capítulo 3.3 más el hook useCanal del 4.2. Así recibe el escritorio el aviso de llamada entrante que el teléfono detectó (capítulo 5.4), mostrando además una notificación nativa de Windows con el plugin oficial:
npm run tauri add notification # instala y registra tauri-plugin-notification
import { useState } from "react";
import { sendNotification } from "@tauri-apps/plugin-notification";
import { useCanal } from "../../hooks/useCanal";
interface Llamada { telefono: string; cliente?: string; ts: string; }
export function AvisoLlamada() {
const [llamada, setLlamada] = useState<Llamada | null>(null);
useCanal("notificaciones", (msg) => {
if (msg.type !== "LLAMADA_ENTRANTE") return;
setLlamada(msg.datos); // popup dentro de la app
sendNotification({ // y notificación del SO
title: `Llamada: ${msg.datos.cliente ?? "desconocido"}`,
body: `${msg.datos.telefono} — abre su historial de pedidos`,
});
});
if (!llamada) return null;
return (
<aside className="popup-llamada">
<h3>📞 {llamada.cliente ?? "Número nuevo"}</h3>
<p>{llamada.telefono}</p>
<button onClick={() => abrirHistorial(llamada.telefono)}>
Ver pedidos anteriores
</button>
<button onClick={() => setLlamada(null)}>Cerrar</button>
</aside>
);
}
La vía B se construye con un servidor HTTP embebido en el proceso Rust. El crate tiny_http es suficiente y no trae dependencias pesadas. El servidor corre en un hilo y le pasa los datos al webview con eventos de Tauri (el canal inverso a invoke):
// Cargo.toml: tiny_http = "0.12", local-ip-address = "0.6"
use tauri::{AppHandle, Emitter};
pub fn iniciar(app: AppHandle) {
std::thread::spawn(move || {
let servidor = tiny_http::Server::http("0.0.0.0:7420").unwrap();
for mut peticion in servidor.incoming_requests() {
let mut cuerpo = String::new();
use std::io::Read;
peticion.as_reader().read_to_string(&mut cuerpo).ok();
// avisamos a React: cualquier componente puede escuchar
app.emit("dato-del-telefono", &cuerpo).ok();
peticion.respond(tiny_http::Response::from_string("ok")).ok();
}
});
}
/// La IP local de esta PC, para mostrarla en un QR de emparejamiento.
#[tauri::command]
pub fn ip_local() -> Result<String, String> {
local_ip_address::local_ip()
.map(|ip| ip.to_string())
.map_err(|e| e.to_string())
}
import { listen } from "@tauri-apps/api/event";
// espejo del app.emit() de Rust:
await listen<string>("dato-del-telefono", (evento) => {
const datos = JSON.parse(evento.payload);
console.log("el teléfono mandó:", datos);
});
El problema del descubrimiento: ¿cómo sabe el teléfono la IP?
- Código QR (simple y suficiente): el escritorio muestra un QR con
http://192.168.1.37:7420(usandoip_local()de arriba y el componenteqrcode.reactdel 4.6); el teléfono lo escanea y ya sabe a dónde mandar. Es el mismo truco de emparejamiento que usa WhatsApp Web. - mDNS (automático): la PC se anuncia como
caja.localen la red con el cratemdns-sd, y Flutter la descubre con el paquetebonsoirsin escanear nada. Más elegante, más piezas móviles; impleméntalo solo si el QR se queda corto.
4.5 Graficar datos de ventas
Para las gráficas del panel usaremos Recharts, la librería declarativa más usada con React: describes la gráfica como JSX y ella dibuja el SVG. Los datos vienen agregados del backend (GET /api/reportes/ventas) — agregar es trabajo del servidor: nunca mandes diez mil pedidos crudos a la app para sumarlos en JavaScript.
npm install recharts
Antes del código, cuatro reglas de diseño de gráficas que separan un panel profesional de uno amateur:
- La pregunta elige la forma. ¿Comparar magnitudes entre categorías (ventas por día)? Barras. ¿Evolución en el tiempo (ventas por hora)? Línea. ¿Un solo número que importa (ventas de hoy)? No es una gráfica: es un número grande con etiqueta. Evita el pastel: comparar ángulos es difícil; un ranking de platillos se lee mejor como barras horizontales.
- Un color basta. Una serie = un solo tono (nuestro azul cocina); el color es para distinguir series, no para decorar. Si algún día hay dos series (comedor vs domicilio), dos tonos fijos y una leyenda.
- Rejilla y ejes discretos, datos protagonistas: rejilla punteada tenue, sin bordes, barras delgadas con esquina superior apenas redondeada.
- Tooltip siempre: el detalle exacto aparece al pasar el cursor, no impreso sobre cada barra.
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import { obtenerVentasSemana } from "../../api/reportes";
// El backend responde: [{ dia: "Lun", total: 8420 }, …]
export function VentasSemana() {
const { data, isLoading } = useQuery({
queryKey: ["ventas-semana"],
queryFn: obtenerVentasSemana,
});
if (isLoading) return <p>Cargando ventas…</p>;
return (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E4E1D8" vertical={false} />
<XAxis dataKey="dia" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} width={56}
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => [`$${v.toLocaleString()}`, "Ventas"]}
cursor={{ fill: "rgba(30, 95, 168, 0.08)" }} />
<Bar dataKey="total" fill="#1E5FA8" radius={[4, 4, 0, 0]}
maxBarSize={38} />
</BarChart>
</ResponsiveContainer>
);
}
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip,
} from "recharts";
// datos: [{ nombre: "Tacos de pastor", vendidos: 182 }, …] ya ordenados
export function TopPlatillos({ datos }: { datos: { nombre: string; vendidos: number }[] }) {
return (
<ResponsiveContainer width="100%" height={datos.length * 42}>
<BarChart data={datos} layout="vertical"
margin={{ top: 0, right: 24, left: 8, bottom: 0 }}>
{/* en layout vertical los ejes se invierten: X numérico, Y categórico */}
<XAxis type="number" hide />
<YAxis type="category" dataKey="nombre" width={150}
tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [v, "vendidos"]} />
<Bar dataKey="vendidos" fill="#1E5FA8" radius={[0, 4, 4, 0]}
maxBarSize={22} label={{ position: "right", fontSize: 12 }} />
</BarChart>
</ResponsiveContainer>
);
}
Alternativas a Recharts, por si tu caso cambia: chart.js (canvas, mejor con miles de puntos), visx (piezas de bajo nivel de Airbnb, control total), echarts (todo incluido, pesada). Para un panel de restaurante, Recharts es el equilibrio correcto entre simplicidad y resultado.
4.6 Distribuir la app móvil por la red local
Tus meseros no van a instalar la app desde Google Play: es una app interna. La solución clásica es que la app de escritorio (o el backend) sirva el archivo APK por HTTP y muestre un QR; el teléfono lo escanea, descarga e instala. El flujo completo:
- Compilas el APK:
flutter build apk --release→build/app/outputs/flutter-apk/app-release.apk. - Copias el archivo a la carpeta que sirve el backend como estático (
/descargas/app.apken el contrato del cap. 3.2) — o dejas que la propia app Tauri lo sirva con el servidor embebido del 4.4. - El escritorio muestra un QR con la URL de descarga.
- En el teléfono Android: abrir el enlace, permitir "instalar apps desconocidas" para el navegador (Ajustes → Apps → Acceso especial), instalar.
npm install qrcode.react
import { QRCodeSVG } from "qrcode.react";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
export function QrApk() {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
// si el APK lo sirve el backend, la URL es fija;
// si lo sirve esta PC (4.4), usamos su IP local:
invoke<string>("ip_local").then((ip) =>
setUrl(`http://${ip}:7420/descargas/app.apk`));
}, []);
if (!url) return null;
return (
<figure className="qr-apk">
<QRCodeSVG value={url} size={220} marginSize={2} />
<figcaption>
Escanea con el teléfono para instalar la app de meseros
<br /><code>{url}</code>
</figcaption>
</figure>
);
}
Para que tiny_http sirva el archivo, agrega al servidor del 4.4 una rama que responda la ruta /descargas/app.apk con el contenido del archivo y el header Content-Type: application/vnd.android.package-archive (ese content-type es lo que hace que Android ofrezca instalarlo en lugar de solo descargarlo).
Esto es solo para Android. iOS no permite instalar apps fuera del App Store salvo con cuentas empresariales o TestFlight. Si tus meseros usarán iPhones, el camino realista es TestFlight (gratuito, hasta 100 probadores internos). También: cada vez que compiles una versión nueva, incrementa versionCode en Flutter para que Android acepte actualizar sobre la instalada.
4.7 Pedir un ticket impreso: el flujo completo
Cerramos el capítulo uniendo las piezas: qué pasa exactamente cuando la cajera pulsa "Imprimir cuenta". Es el patrón escribir por REST, confirmar por WebSocket aplicado a la impresión:
import { useState } from "react";
import { useCanal } from "../../hooks/useCanal";
import { api } from "../../api/cliente";
type Estado = "reposo" | "enviando" | "imprimiendo" | "listo" | "error";
export function BotonImprimir({ pedidoId }: { pedidoId: number }) {
const [estado, setEstado] = useState<Estado>("reposo");
useCanal("notificaciones", (msg) => {
if (msg.type === "TICKET_IMPRESO" && msg.datos.pedidoId === pedidoId) {
setEstado("listo");
setTimeout(() => setEstado("reposo"), 2500);
}
if (msg.type === "TICKET_FALLO" && msg.datos.pedidoId === pedidoId) {
setEstado("error"); // "revisa papel / conexión de la impresora"
}
});
async function imprimir() {
setEstado("enviando");
try {
await api.post(`/api/pedidos/${pedidoId}/ticket`);
setEstado("imprimiendo"); // 202: encolado, esperamos el WS
} catch {
setEstado("error");
}
}
return (
<button onClick={imprimir} disabled={estado === "enviando" || estado === "imprimiendo"}>
{estado === "reposo" && "Imprimir cuenta"}
{estado === "enviando" && "Enviando…"}
{estado === "imprimiendo" && "Imprimiendo…"}
{estado === "listo" && "Impreso ✓"}
{estado === "error" && "Falló — reintentar"}
</button>
);
}
Deshabilitar el botón mientras el trabajo está en vuelo evita el clásico "pulsé tres veces porque no pasaba nada" que termina en tres tickets impresos. Es el mismo principio de idempotencia del capítulo 1.3, resuelto en la UI. Para reimprimir de verdad (se atascó el papel), el mismo endpoint sirve: cada POST es un trabajo de impresión nuevo, y eso es exactamente lo que quieres.