refactor(ui): Rediseño completo de UI con Nuxt UI 4

- Nuevo layout responsivo mobile-first con tabs inferiores
- Sidebar colapsable en desktop con cola de impresión
- Sistema de templates reutilizables con localStorage
- Soporte Dark/Light mode con UColorModeButton
- Composables usePrintQueue y useTemplates para estado global
- Componentes modulares: CommandBuilder, QuickActions, PrintQueue, QueueItem
- Navegación por tabs: Constructor | Cola | Templates
This commit is contained in:
2025-11-24 17:46:20 -06:00
parent f3c13b356b
commit 470ecef4f1
39 changed files with 16114 additions and 1856 deletions

286
server/utils/eposBuilder.ts Normal file
View File

@@ -0,0 +1,286 @@
// Server-side minimal ePOS-Print XML builder for Epson printers
// Focused on printer-only tags. Builds the inner message for <epos-print>.
// Tipos para las operaciones
export interface Operation {
op: string
[key: string]: any
}
export interface TextStyleOptions {
reverse?: boolean
ul?: boolean
em?: boolean
color?: string
}
export interface PulseOptions {
drawer?: string
time?: string
}
export interface BarcodeOptions {
type?: string
hri?: string
font?: string
width?: number
height?: number
}
export interface QRCodeOptions {
model?: string
level?: string
size?: number
}
function escapeXml(str: string | number): string {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export class EposMessageBuilder {
private parts: string[] = []
// Text and styles
text(content: string): this {
this.parts.push(`<text>${escapeXml(content)}</text>`)
return this
}
textLang(lang: string): this {
this.parts.push(`<text lang="${lang}"/>`)
return this
}
textAlign(align: string): this {
this.parts.push(`<text align="${align}"/>`)
return this
}
textRotate(rotate: boolean): this {
this.parts.push(`<text rotate="${rotate ? 'true' : 'false'}"/>`)
return this
}
textLineSpace(linespc: number): this {
this.parts.push(`<text linespc="${linespc}"/>`)
return this
}
textFont(font: string): this {
this.parts.push(`<text font="${font}"/>`)
return this
}
textSmooth(smooth: boolean): this {
this.parts.push(`<text smooth="${smooth ? 'true' : 'false'}"/>`)
return this
}
textDouble(dw?: boolean, dh?: boolean): this {
const attrs = [
dw !== undefined ? `dw="${dw ? 'true' : 'false'}"` : '',
dh !== undefined ? `dh="${dh ? 'true' : 'false'}"` : ''
].filter(Boolean).join(' ')
this.parts.push(`<text ${attrs}/>`.trim())
return this
}
textSize(width?: number, height?: number): this {
const attrs = [
width !== undefined ? `width="${width}"` : '',
height !== undefined ? `height="${height}"` : ''
].filter(Boolean).join(' ')
this.parts.push(`<text ${attrs}/>`.trim())
return this
}
textStyle(options: TextStyleOptions = {}): this {
const { reverse, ul, em, color } = options
const attrs: string[] = []
if (reverse !== undefined) attrs.push(`reverse="${reverse ? 'true' : 'false'}"`)
if (ul !== undefined) attrs.push(`ul="${ul ? 'true' : 'false'}"`)
if (em !== undefined) attrs.push(`em="${em ? 'true' : 'false'}"`)
if (color !== undefined) attrs.push(`color="${color}"`)
this.parts.push(`<text ${attrs.join(' ')}/>`.trim())
return this
}
textPosition(x: number): this {
this.parts.push(`<text x="${x}"/>`)
return this
}
textVPosition(y: number): this {
this.parts.push(`<text y="${y}"/>`)
return this
}
// Feed
feed(): this {
this.parts.push('<feed/>')
return this
}
feedUnit(unit: number): this {
this.parts.push(`<feed unit="${unit}"/>`)
return this
}
feedLine(line: number): this {
this.parts.push(`<feed line="${line}"/>`)
return this
}
feedPosition(pos: number): this {
this.parts.push(`<feed pos="${pos}"/>`)
return this
}
// Cut
cut(type: string): this {
this.parts.push(`<cut type="${type}"/>`)
return this
}
// Drawer
pulse(options: PulseOptions = {}): this {
const { drawer = 'drawer_1', time = 'pulse_200' } = options
this.parts.push(`<pulse drawer="${drawer}" time="${time}"/>`)
return this
}
// Barcode and symbols
barcode(data: string, options: BarcodeOptions = {}): this {
const { type, hri, font, width, height } = options
const attrs: string[] = []
if (type) attrs.push(`type="${type}"`)
if (hri) attrs.push(`hri="${hri}"`)
if (font) attrs.push(`font="${font}"`)
if (width) attrs.push(`width="${width}"`)
if (height) attrs.push(`height="${height}"`)
this.parts.push(`<barcode ${attrs.join(' ')}>${escapeXml(data)}</barcode>`.trim())
return this
}
qrcode(data: string, options: QRCodeOptions = {}): this {
const { model = 'qrcode_model_2', level = 'level_m', size } = options
const attrs = [`type="${model}"`, `level="${level}"`]
if (size) attrs.push(`size="${size}"`)
this.parts.push(`<symbol ${attrs.join(' ')}>${escapeXml(data)}</symbol>`)
return this
}
// Image
imageRaw(options: {
width: number
height: number
mode?: string
base64: string
}): this {
const { width, height, mode = 'mono', base64 } = options
this.parts.push(`<image width="${width}" height="${height}" mode="${mode}">${base64}</image>`)
return this
}
build(): string {
return this.parts.join('')
}
}
// Accepts a list of operations: [{ op: 'text', args: {...} | [] }, ...]
export function buildFromOperations(ops: Operation[]): string {
const b = new EposMessageBuilder()
for (const item of ops || []) {
const { op } = item
if (!op) continue
switch (op) {
case 'text':
b.text(item.value ?? '')
break
case 'textLang':
b.textLang(item.lang)
break
case 'textAlign':
b.textAlign(item.align)
break
case 'textRotate':
b.textRotate(!!item.rotate)
break
case 'textLineSpace':
b.textLineSpace(item.linespc)
break
case 'textFont':
b.textFont(item.font)
break
case 'textSmooth':
b.textSmooth(!!item.smooth)
break
case 'textDouble':
b.textDouble(item.dw, item.dh)
break
case 'textSize':
b.textSize(item.width, item.height)
break
case 'textStyle':
b.textStyle({
reverse: item.reverse,
ul: item.ul,
em: item.em,
color: item.color
})
break
case 'textPosition':
b.textPosition(item.x)
break
case 'textVPosition':
b.textVPosition(item.y)
break
case 'feed':
b.feed()
break
case 'feedUnit':
b.feedUnit(item.unit)
break
case 'feedLine':
b.feedLine(item.line)
break
case 'feedPosition':
b.feedPosition(item.pos)
break
case 'cut':
b.cut(item.type || 'feed')
break
case 'pulse':
b.pulse({ drawer: item.drawer, time: item.time })
break
case 'barcode':
b.barcode(item.data || '', {
type: item.type,
hri: item.hri,
font: item.font,
width: item.width,
height: item.height
})
break
case 'qrcode':
b.qrcode(item.data || '', {
model: item.model,
level: item.level,
size: item.size
})
break
default:
// ignore unknown for now
break
}
}
return b.build()
}

62
server/utils/printer.ts Normal file
View File

@@ -0,0 +1,62 @@
// Utilidades para comunicación con impresoras Epson ePOS
import axios from 'axios'
import https from 'https'
export interface PrinterResponse {
status: number
headers: any
data: string
}
export function buildSoapEnvelope(inner: string): string {
return (
'<?xml version="1.0" encoding="utf-8"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">' +
'<s:Body>' +
'<epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">' +
inner +
'</epos-print>' +
'</s:Body>' +
'</s:Envelope>'
)
}
export async function sendToPrinter(
xml: string,
printerHost: string,
printerDeviceId: string,
printerTimeoutMs: number
): Promise<PrinterResponse> {
const url = `https://${printerHost}/cgi-bin/epos/service.cgi?devid=${encodeURIComponent(printerDeviceId)}&timeout=${printerTimeoutMs}`
// Agente HTTPS que acepta certificados auto-firmados
const httpsAgent = new https.Agent({
rejectUnauthorized: false
})
const res = await axios.post(url, xml, {
headers: {
'Content-Type': 'text/xml; charset=utf-8'
},
httpsAgent,
timeout: printerTimeoutMs + 5000,
validateStatus: () => true, // Aceptar cualquier status code
})
return {
status: res.status,
headers: res.headers,
data: typeof res.data === 'string' ? res.data : String(res.data)
}
}
export function parsePrinterResponse(responseData: string): {
success: boolean
code: string
} {
const success = /success\s*=\s*"true"/.test(responseData)
const codeMatch = responseData.match(/code="([^"]*)"/)
const code = codeMatch ? codeMatch[1] : ''
return { success, code }
}