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

View File

@@ -0,0 +1,36 @@
// Endpoint para cortar papel
import { buildFromOperations } from '~/server/utils/eposBuilder'
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const body = await readBody(event)
const { type = 'feed' } = body as { type?: string }
const inner = buildFromOperations([{ op: 'cut', type }])
const soap = buildSoapEnvelope(inner)
const result = await sendToPrinter(
soap,
config.printerHost,
config.printerDeviceId,
parseInt(config.printerTimeoutMs)
)
const { success, code } = parsePrinterResponse(result.data)
return {
ok: success,
httpStatus: result.status,
code,
raw: result.data
}
} catch (err: any) {
return {
ok: false,
error: err.message
}
}
})

View File

@@ -0,0 +1,120 @@
// Endpoint para imprimir imágenes usando Jimp
import Jimp from 'jimp'
import { EposMessageBuilder } from '~/server/utils/eposBuilder'
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const body = await readBody(event)
const {
path,
width,
threshold = 128,
mode = 'mono'
} = body as {
path?: string
width?: number
threshold?: number
mode?: string
}
if (!path) {
return {
ok: false,
error: 'path required'
}
}
// Leer y procesar imagen
const img = await Jimp.read(path)
let targetWidth = width || img.bitmap.width
if (targetWidth <= 0) targetWidth = img.bitmap.width
const scale = targetWidth / img.bitmap.width
const targetHeight = Math.max(1, Math.round(img.bitmap.height * scale))
img.resize(targetWidth, targetHeight, Jimp.RESIZE_BILINEAR)
img.grayscale()
// Empaquetar bits MSB first por byte
const bytesPerRow = Math.ceil(targetWidth / 8)
const out = Buffer.alloc(bytesPerRow * targetHeight)
let outIdx = 0
for (let y = 0; y < targetHeight; y++) {
let byte = 0
let bit = 7
let rowBytes = 0
for (let x = 0; x < targetWidth; x++) {
const idx = (y * targetWidth + x) * 4
const rgba = img.bitmap.data
const r = rgba[idx]
const g = rgba[idx + 1]
const b = rgba[idx + 2]
const lum = 0.299 * r + 0.587 * g + 0.114 * b
const isBlack = lum < threshold
if (isBlack) byte |= (1 << bit)
bit--
if (bit < 0) {
out[outIdx++] = byte
rowBytes++
byte = 0
bit = 7
}
}
// Rellenar bits restantes
if (bit !== 7) {
out[outIdx++] = byte
rowBytes++
}
// Rellenar a bytes completos si es necesario
while (rowBytes < bytesPerRow) {
out[outIdx++] = 0
rowBytes++
}
}
const base64 = out.toString('base64')
// Construir mensaje ePOS
const builder = new EposMessageBuilder()
builder.imageRaw({
width: targetWidth,
height: targetHeight,
mode,
base64
})
const soap = buildSoapEnvelope(builder.build())
const result = await sendToPrinter(
soap,
config.printerHost,
config.printerDeviceId,
parseInt(config.printerTimeoutMs)
)
const { success, code } = parsePrinterResponse(result.data)
return {
ok: success,
httpStatus: result.status,
code,
raw: result.data,
width: targetWidth,
height: targetHeight
}
} catch (err: any) {
return {
ok: false,
error: err.message
}
}
})

View File

@@ -0,0 +1,54 @@
// Endpoint genérico de impresión que acepta una lista de operaciones
import { buildFromOperations, type Operation } from '~/server/utils/eposBuilder'
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const body = await readBody(event)
const {
operations = [],
dryRun = false
} = body as {
operations?: Operation[]
dryRun?: boolean
}
// Construir el XML interior con las operaciones
const inner = buildFromOperations(operations)
const soap = buildSoapEnvelope(inner)
// Si es dryRun, devolver solo el XML sin enviar a la impresora
if (dryRun) {
return {
ok: true,
dryRun: true,
soap
}
}
// Enviar a la impresora
const result = await sendToPrinter(
soap,
config.printerHost,
config.printerDeviceId,
parseInt(config.printerTimeoutMs)
)
// Parsear la respuesta
const { success, code } = parsePrinterResponse(result.data)
return {
ok: success,
httpStatus: result.status,
code,
raw: result.data
}
} catch (err: any) {
return {
ok: false,
error: err.message
}
}
})

View File

@@ -0,0 +1,39 @@
// Endpoint para abrir cajón de dinero
import { buildFromOperations } from '~/server/utils/eposBuilder'
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const body = await readBody(event)
const { drawer, time } = body as {
drawer?: string
time?: string
}
const inner = buildFromOperations([{ op: 'pulse', drawer, time }])
const soap = buildSoapEnvelope(inner)
const result = await sendToPrinter(
soap,
config.printerHost,
config.printerDeviceId,
parseInt(config.printerTimeoutMs)
)
const { success, code } = parsePrinterResponse(result.data)
return {
ok: success,
httpStatus: result.status,
code,
raw: result.data
}
} catch (err: any) {
return {
ok: false,
error: err.message
}
}
})

View File

@@ -0,0 +1,86 @@
// Endpoint de conveniencia para imprimir texto con opciones
import { buildFromOperations, type Operation } from '~/server/utils/eposBuilder'
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const body = await readBody(event)
const {
text = '',
options = {}
} = body as {
text?: string
options?: {
align?: string
font?: string
size?: { width?: number, height?: number }
style?: {
reverse?: boolean
ul?: boolean
em?: boolean
color?: string
}
feedLines?: number
cut?: string
}
}
// Construir operaciones
const ops: Operation[] = []
if (options.align) {
ops.push({ op: 'textAlign', align: options.align })
}
if (options.font) {
ops.push({ op: 'textFont', font: options.font })
}
if (options.size) {
ops.push({
op: 'textSize',
width: options.size.width,
height: options.size.height
})
}
if (options.style) {
ops.push({ op: 'textStyle', ...options.style })
}
// Agregar el texto
ops.push({ op: 'text', value: text })
// Opciones post-texto
if (options.feedLines) {
ops.push({ op: 'feedLine', line: options.feedLines })
}
if (options.cut) {
ops.push({ op: 'cut', type: options.cut })
}
// Construir SOAP y enviar
const inner = buildFromOperations(ops)
const soap = buildSoapEnvelope(inner)
const result = await sendToPrinter(
soap,
config.printerHost,
config.printerDeviceId,
parseInt(config.printerTimeoutMs)
)
const { success, code } = parsePrinterResponse(result.data)
return {
ok: success,
httpStatus: result.status,
code,
raw: result.data
}
} catch (err: any) {
return {
ok: false,
error: err.message
}
}
})