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

12
app/app.config.ts Normal file
View File

@@ -0,0 +1,12 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'zinc',
secondary: 'slate',
success: 'emerald',
info: 'sky',
warning: 'amber',
error: 'rose'
}
}
})

5
app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<UApp>
<NuxtPage />
</UApp>
</template>

34
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,34 @@
/* Safe area para PWA en iOS */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Padding inferior para contenido con navegación mobile */
.pb-mobile-nav {
padding-bottom: calc(4rem + env(safe-area-inset-bottom));
}
/* Scrollbar personalizado */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-600;
}
/* Reset básico */
html, body, #__nuxt {
height: 100%;
margin: 0;
padding: 0;
}

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
const queue = usePrintQueue()
// Estado del formulario
const text = ref('')
const align = ref('')
const font = ref('')
const width = ref<number | undefined>()
const height = ref<number | undefined>()
const bold = ref(false)
const underline = ref(false)
const reverse = ref(false)
const smooth = ref(false)
const color = ref('')
const feedLines = ref<number | undefined>()
const cut = ref('')
// Opciones para selects
const alignOptions = [
{ label: '(sin cambio)', value: '' },
{ label: 'Izquierda', value: 'left' },
{ label: 'Centro', value: 'center' },
{ label: 'Derecha', value: 'right' }
]
const fontOptions = [
{ label: '(sin cambio)', value: '' },
{ label: 'Font A', value: 'font_a' },
{ label: 'Font B', value: 'font_b' },
{ label: 'Font C', value: 'font_c' },
{ label: 'Font D', value: 'font_d' },
{ label: 'Font E', value: 'font_e' },
{ label: 'Special A', value: 'special_a' },
{ label: 'Special B', value: 'special_b' }
]
const colorOptions = [
{ label: '(default)', value: '' },
{ label: 'Color 1', value: 'color_1' },
{ label: 'Color 2', value: 'color_2' },
{ label: 'Color 3', value: 'color_3' },
{ label: 'Color 4', value: 'color_4' }
]
const cutOptions = [
{ label: '(no cortar)', value: '' },
{ label: 'Sin feed', value: 'no_feed' },
{ label: 'Con feed', value: 'feed' },
{ label: 'Reservar', value: 'reserve' },
{ label: 'Full sin feed', value: 'no_feed_fullcut' },
{ label: 'Full con feed', value: 'feed_fullcut' },
{ label: 'Full reservar', value: 'reserve_fullcut' }
]
const formatSections = [
{ label: 'Alineación y Fuente', value: 'alignment', icon: 'i-heroicons-bars-3-bottom-left' },
{ label: 'Tamaño', value: 'size', icon: 'i-heroicons-arrows-pointing-out' },
{ label: 'Estilo', value: 'style', icon: 'i-heroicons-paint-brush' },
{ label: 'Acciones finales', value: 'actions', icon: 'i-heroicons-scissors' }
]
function resetForm() {
text.value = ''
align.value = ''
font.value = ''
width.value = undefined
height.value = undefined
bold.value = false
underline.value = false
reverse.value = false
smooth.value = false
color.value = ''
feedLines.value = undefined
cut.value = ''
}
function queueText() {
const ops: any[] = []
if (align.value) ops.push({ op: 'textAlign', align: align.value })
if (font.value) ops.push({ op: 'textFont', font: font.value })
if (width.value || height.value) {
ops.push({ op: 'textSize', width: width.value, height: height.value })
}
ops.push({
op: 'textStyle',
em: bold.value,
ul: underline.value,
reverse: reverse.value,
...(color.value ? { color: color.value } : {})
})
if (text.value) ops.push({ op: 'text', value: text.value })
if (feedLines.value != null && feedLines.value !== 0) {
ops.push({ op: 'feedLine', line: Number(feedLines.value) })
}
if (cut.value) ops.push({ op: 'cut', type: cut.value })
queue.addOperations(ops)
}
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Constructor de Comandos
</h2>
<UButton
icon="i-heroicons-arrow-path"
variant="ghost"
size="sm"
@click="resetForm"
/>
</div>
</template>
<!-- Textarea principal -->
<UFormField label="Texto a imprimir" class="mb-4">
<UTextarea
v-model="text"
:rows="4"
autoresize
placeholder="Escribe el texto a imprimir..."
/>
</UFormField>
<!-- Controles de formato con Accordion -->
<UAccordion :items="formatSections" type="multiple">
<template #alignment>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3">
<UFormField label="Alineación">
<USelect v-model="align" :items="alignOptions" placeholder="Seleccionar" />
</UFormField>
<UFormField label="Fuente">
<USelect v-model="font" :items="fontOptions" placeholder="Seleccionar" />
</UFormField>
<UFormField label="Color">
<USelect v-model="color" :items="colorOptions" placeholder="Seleccionar" />
</UFormField>
</div>
</template>
<template #size>
<div class="grid grid-cols-2 gap-3 p-3">
<UFormField label="Ancho (1-8)">
<UInput v-model.number="width" type="number" :min="1" :max="8" placeholder="1" />
</UFormField>
<UFormField label="Alto (1-8)">
<UInput v-model.number="height" type="number" :min="1" :max="8" placeholder="1" />
</UFormField>
</div>
</template>
<template #style>
<div class="flex flex-wrap gap-4 p-3">
<UCheckbox v-model="bold" label="Negrita" />
<UCheckbox v-model="underline" label="Subrayado" />
<UCheckbox v-model="reverse" label="Invertido" />
<UCheckbox v-model="smooth" label="Suavizado" />
</div>
</template>
<template #actions>
<div class="grid grid-cols-2 gap-3 p-3">
<UFormField label="Líneas de feed">
<UInput v-model.number="feedLines" type="number" :min="0" :max="255" placeholder="0" />
</UFormField>
<UFormField label="Corte de papel">
<USelect v-model="cut" :items="cutOptions" placeholder="Seleccionar" />
</UFormField>
</div>
</template>
</UAccordion>
<template #footer>
<div class="flex gap-2">
<UButton color="primary" @click="queueText">
Agregar a cola
</UButton>
<UButton variant="ghost" @click="resetForm">
Limpiar formulario
</UButton>
</div>
</template>
</UCard>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
const queue = usePrintQueue()
const quickActions = [
{
label: 'Feed 2',
icon: 'i-heroicons-arrow-down',
ops: [{ op: 'feedLine', line: 2 }]
},
{
label: 'Cortar',
icon: 'i-heroicons-scissors',
ops: [{ op: 'cut', type: 'feed' }]
},
{
label: 'Pulse',
icon: 'i-heroicons-bolt',
ops: [{ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' }]
},
{
label: 'QR',
icon: 'i-heroicons-qr-code',
ops: [{ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 }]
},
{
label: 'Barcode',
icon: 'i-heroicons-bars-3',
ops: [{ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 }]
}
]
</script>
<template>
<UCard variant="soft">
<template #header>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">
Atajos rápidos
</span>
</template>
<div class="flex flex-wrap gap-2">
<UButton
v-for="action in quickActions"
:key="action.label"
variant="outline"
size="sm"
:icon="action.icon"
@click="queue.addOperations(action.ops)"
>
{{ action.label }}
</UButton>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const queue = usePrintQueue()
</script>
<template>
<header class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950">
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
PrinterCentral
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
Control de impresoras Epson ePOS
</p>
</div>
<div class="flex items-center gap-2">
<!-- Contador de cola (visible en desktop) -->
<UBadge
v-if="queue.operations.value.length > 0"
color="primary"
variant="subtle"
class="hidden md:flex"
>
{{ queue.operations.value.length }} en cola
</UBadge>
<!-- Toggle Dark/Light mode -->
<UColorModeButton />
</div>
</header>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
const activeTab = defineModel<string>({ default: 'constructor' })
const queue = usePrintQueue()
const tabs = computed(() => [
{
label: 'Constructor',
value: 'constructor',
icon: 'i-heroicons-pencil-square'
},
{
label: `Cola (${queue.operations.value.length})`,
value: 'queue',
icon: 'i-heroicons-queue-list'
},
{
label: 'Templates',
value: 'templates',
icon: 'i-heroicons-document-duplicate'
}
])
</script>
<template>
<nav class="fixed bottom-0 left-0 right-0 bg-white/95 dark:bg-gray-950/95 backdrop-blur border-t border-gray-200 dark:border-gray-800 md:hidden z-50 safe-area-bottom">
<UTabs
v-model="activeTab"
:items="tabs"
variant="pill"
color="neutral"
:content="false"
class="justify-center py-2"
:ui="{
list: 'justify-center gap-1',
trigger: 'px-3 py-2'
}"
/>
</nav>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
const queue = usePrintQueue()
</script>
<template>
<div class="space-y-3">
<div v-if="queue.operations.value.length === 0" class="text-center py-8">
<UIcon name="i-heroicons-queue-list" class="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-2" />
<p class="text-gray-500 dark:text-gray-400">
No hay comandos en la cola
</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Usa el constructor para agregar comandos
</p>
</div>
<TransitionGroup name="list" tag="div" class="space-y-2">
<QueueQueueItem
v-for="(op, index) in queue.operations.value"
:key="index"
:operation="op"
:index="index"
:is-first="index === 0"
:is-last="index === queue.operations.value.length - 1"
@update="(newOp) => queue.updateOperation(index, newOp)"
@remove="queue.removeOperation(index)"
@move-up="queue.moveOperation(index, 'up')"
@move-down="queue.moveOperation(index, 'down')"
/>
</TransitionGroup>
</div>
</template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.list-move {
transition: transform 0.3s ease;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
const queue = usePrintQueue()
const templates = useTemplates()
const saveDrawerOpen = ref(false)
const templateName = ref('')
const templateDescription = ref('')
function saveAsTemplate() {
if (!templateName.value.trim()) return
templates.saveTemplate(
templateName.value.trim(),
templateDescription.value.trim(),
queue.operations.value as any
)
templateName.value = ''
templateDescription.value = ''
saveDrawerOpen.value = false
}
</script>
<template>
<div class="space-y-3">
<!-- Resultado de la última impresión -->
<UCard v-if="queue.result.value" variant="soft" :class="queue.result.value.ok ? '' : 'border-red-500'">
<div class="flex items-center gap-2">
<UIcon
:name="queue.result.value.ok ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-circle'"
:class="queue.result.value.ok ? 'text-green-500' : 'text-red-500'"
class="w-5 h-5"
/>
<span class="text-sm">
{{ queue.result.value.ok ? (queue.result.value.msg || 'Listo') : (queue.result.value.error || 'Error') }}
</span>
</div>
<pre v-if="queue.result.value.code" class="text-xs text-gray-500 mt-2 overflow-auto">{{ queue.result.value.code }}</pre>
</UCard>
<!-- Botones de acción -->
<div class="flex flex-wrap gap-2">
<UButton
color="primary"
:loading="queue.loading.value"
:disabled="queue.operations.value.length === 0"
@click="queue.sendToPrinter"
>
<template #leading>
<UIcon name="i-heroicons-printer" class="w-4 h-4" />
</template>
Imprimir ({{ queue.operations.value.length }})
</UButton>
<UButton
variant="outline"
:disabled="queue.operations.value.length === 0"
@click="saveDrawerOpen = true"
>
<template #leading>
<UIcon name="i-heroicons-bookmark" class="w-4 h-4" />
</template>
Guardar template
</UButton>
<UButton
variant="ghost"
color="error"
:disabled="queue.operations.value.length === 0"
@click="queue.clearQueue"
>
<template #leading>
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</template>
Limpiar
</UButton>
</div>
<!-- Drawer para guardar template -->
<UDrawer v-model:open="saveDrawerOpen" direction="bottom" title="Guardar como Template">
<template #body>
<div class="space-y-4 p-4">
<UFormField label="Nombre del template" required>
<UInput v-model="templateName" placeholder="Ej: Ticket de venta" />
</UFormField>
<UFormField label="Descripción (opcional)">
<UTextarea
v-model="templateDescription"
:rows="2"
placeholder="Breve descripción del template..."
/>
</UFormField>
<p class="text-sm text-gray-500 dark:text-gray-400">
Se guardarán {{ queue.operations.value.length }} comandos de la cola actual.
</p>
</div>
</template>
<template #footer>
<div class="flex gap-2 justify-end p-4">
<UButton variant="ghost" @click="saveDrawerOpen = false">
Cancelar
</UButton>
<UButton color="primary" :disabled="!templateName.trim()" @click="saveAsTemplate">
Guardar
</UButton>
</div>
</template>
</UDrawer>
</div>
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { Operation } from '~/composables/usePrintQueue'
const props = defineProps<{
operation: Operation
index: number
isFirst: boolean
isLast: boolean
}>()
const emit = defineEmits<{
update: [op: Operation]
remove: []
'move-up': []
'move-down': []
}>()
const isEditing = ref(false)
const editableFields = reactive<Record<string, string>>({})
// Color según tipo de operación
const opColor = computed(() => {
const op = props.operation.op
if (op.startsWith('text')) return 'primary'
if (op.startsWith('feed')) return 'info'
if (op === 'cut') return 'warning'
if (op === 'pulse') return 'success'
if (op === 'barcode' || op === 'qrcode') return 'secondary'
return 'neutral'
})
// Icono según tipo de operación
const opIcon = computed(() => {
const op = props.operation.op
if (op === 'text') return 'i-heroicons-document-text'
if (op.startsWith('text')) return 'i-heroicons-adjustments-horizontal'
if (op.startsWith('feed')) return 'i-heroicons-arrow-down'
if (op === 'cut') return 'i-heroicons-scissors'
if (op === 'pulse') return 'i-heroicons-bolt'
if (op === 'barcode') return 'i-heroicons-bars-3'
if (op === 'qrcode') return 'i-heroicons-qr-code'
return 'i-heroicons-cog-6-tooth'
})
// Inicializar campos editables
function initEditableFields() {
Object.keys(editableFields).forEach(k => delete editableFields[k])
for (const [k, v] of Object.entries(props.operation)) {
if (k === 'op') continue
editableFields[k] = typeof v === 'object' ? JSON.stringify(v) : String(v)
}
}
watch(() => props.operation, initEditableFields, { immediate: true, deep: true })
function applyChanges() {
const updated: Operation = { op: props.operation.op }
for (const [k, v] of Object.entries(editableFields)) {
try {
updated[k] = JSON.parse(v)
} catch {
const n = Number(v)
updated[k] = isNaN(n) ? v : n
}
}
emit('update', updated)
isEditing.value = false
}
function cancelEdit() {
initEditableFields()
isEditing.value = false
}
// Resumen legible del comando
const summary = computed(() => {
const op = props.operation
switch (op.op) {
case 'text':
return op.value?.substring(0, 30) + (op.value?.length > 30 ? '...' : '')
case 'textAlign':
return `Alinear: ${op.align}`
case 'textFont':
return `Fuente: ${op.font}`
case 'textSize':
return `Tamaño: ${op.width || 1}x${op.height || 1}`
case 'textStyle':
const styles = []
if (op.em) styles.push('negrita')
if (op.ul) styles.push('subrayado')
if (op.reverse) styles.push('invertido')
return styles.length ? `Estilo: ${styles.join(', ')}` : 'Estilo: normal'
case 'feedLine':
return `Feed: ${op.line} líneas`
case 'cut':
return `Cortar: ${op.type}`
case 'pulse':
return `Pulse: ${op.drawer}`
case 'qrcode':
return `QR: ${op.data?.substring(0, 20)}...`
case 'barcode':
return `Barcode: ${op.data}`
default:
return op.op
}
})
</script>
<template>
<UCard variant="soft" class="group">
<div class="flex items-start gap-3">
<!-- Indicador de tipo -->
<div class="flex flex-col items-center gap-1">
<UBadge :color="opColor" variant="subtle" size="xs">
{{ index + 1 }}
</UBadge>
<UIcon :name="opIcon" class="w-4 h-4 text-gray-400" />
</div>
<!-- Contenido -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-gray-900 dark:text-white">
{{ operation.op }}
</span>
</div>
<!-- Vista normal -->
<p v-if="!isEditing" class="text-sm text-gray-500 dark:text-gray-400 truncate">
{{ summary }}
</p>
<!-- Vista edición -->
<div v-else class="space-y-2 mt-2">
<div v-for="(value, key) in editableFields" :key="key" class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-16 shrink-0">{{ key }}:</span>
<UInput
v-model="editableFields[key]"
size="xs"
class="flex-1"
/>
</div>
<div class="flex gap-2 mt-2">
<UButton size="xs" @click="applyChanges">Aplicar</UButton>
<UButton size="xs" variant="ghost" @click="cancelEdit">Cancelar</UButton>
</div>
</div>
</div>
<!-- Acciones -->
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<UButton
v-if="!isEditing"
icon="i-heroicons-pencil"
variant="ghost"
size="xs"
@click="isEditing = true"
/>
<UButton
icon="i-heroicons-chevron-up"
variant="ghost"
size="xs"
:disabled="isFirst"
@click="$emit('move-up')"
/>
<UButton
icon="i-heroicons-chevron-down"
variant="ghost"
size="xs"
:disabled="isLast"
@click="$emit('move-down')"
/>
<UButton
icon="i-heroicons-trash"
variant="ghost"
size="xs"
color="error"
@click="$emit('remove')"
/>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { PrintTemplate } from '~/composables/useTemplates'
const props = defineProps<{
template: PrintTemplate
}>()
const emit = defineEmits<{
load: [id: string]
duplicate: [id: string]
delete: [id: string]
}>()
const menuItems = computed(() => [
[
{
label: 'Duplicar',
icon: 'i-heroicons-document-duplicate',
click: () => emit('duplicate', props.template.id)
}
],
[
{
label: 'Eliminar',
icon: 'i-heroicons-trash',
color: 'error' as const,
click: () => emit('delete', props.template.id)
}
]
])
function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleDateString('es-AR', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}
</script>
<template>
<UCard variant="outline" class="hover:border-primary-500 dark:hover:border-primary-400 transition-colors">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="font-medium text-gray-900 dark:text-white truncate">
{{ template.name }}
</h3>
<p
v-if="template.description"
class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 mt-1"
>
{{ template.description }}
</p>
<div class="flex items-center gap-2 mt-2">
<UBadge variant="subtle" size="xs">
{{ template.operations.length }} comandos
</UBadge>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(template.updatedAt) }}
</span>
</div>
</div>
<UDropdownMenu :items="menuItems">
<UButton icon="i-heroicons-ellipsis-vertical" variant="ghost" size="sm" />
</UDropdownMenu>
</div>
<template #footer>
<UButton
icon="i-heroicons-play"
color="primary"
block
@click="$emit('load', template.id)"
>
Cargar en cola
</UButton>
</template>
</UCard>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
const templates = useTemplates()
const queue = usePrintQueue()
function loadTemplate(id: string) {
const ops = templates.loadTemplate(id)
if (ops) {
queue.loadFromTemplate(ops)
}
}
function duplicateTemplate(id: string) {
templates.duplicateTemplate(id)
}
function deleteTemplate(id: string) {
templates.deleteTemplate(id)
}
</script>
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Templates guardados
</h2>
<UBadge v-if="templates.templates.value.length > 0" variant="subtle">
{{ templates.templates.value.length }}
</UBadge>
</div>
<div v-if="templates.templates.value.length === 0" class="text-center py-12">
<UIcon name="i-heroicons-document-duplicate" class="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-3" />
<p class="text-gray-500 dark:text-gray-400 mb-2">
No hay templates guardados
</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Crea comandos en el constructor y guárdalos como template desde la cola
</p>
</div>
<div v-else class="grid gap-3 sm:grid-cols-2">
<TemplatesTemplateCard
v-for="template in templates.templates.value"
:key="template.id"
:template="template"
@load="loadTemplate"
@duplicate="duplicateTemplate"
@delete="deleteTemplate"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
export interface Operation {
op: string
[key: string]: any
}
export interface PrintResult {
ok: boolean
msg?: string
error?: string
code?: string
raw?: string
}
export function usePrintQueue() {
const operations = useState<Operation[]>('printQueue', () => [])
const result = useState<PrintResult>('printResult', () => ({ ok: true, msg: 'Listo.' }))
const loading = useState('printLoading', () => false)
function addOperations(ops: Operation[]) {
if (Array.isArray(ops) && ops.length) {
operations.value = [...operations.value, ...ops]
}
}
function updateOperation(index: number, op: Operation) {
operations.value = operations.value.map((o, i) => i === index ? op : o)
}
function removeOperation(index: number) {
operations.value = operations.value.filter((_, i) => i !== index)
}
function moveOperation(from: number, direction: 'up' | 'down') {
const to = direction === 'up' ? from - 1 : from + 1
if (to < 0 || to >= operations.value.length) return
const arr = [...operations.value]
;[arr[from], arr[to]] = [arr[to], arr[from]]
operations.value = arr
}
function clearQueue() {
operations.value = []
}
function loadFromTemplate(ops: Operation[]) {
operations.value = JSON.parse(JSON.stringify(ops))
}
async function sendToPrinter() {
if (operations.value.length === 0) return
loading.value = true
try {
result.value = await $fetch('/api/print', {
method: 'POST',
body: { operations: operations.value }
})
} catch (error: any) {
result.value = {
ok: false,
error: error.message || 'Error al enviar a la impresora'
}
} finally {
loading.value = false
}
}
return {
operations: readonly(operations),
result: readonly(result),
loading: readonly(loading),
addOperations,
updateOperation,
removeOperation,
moveOperation,
clearQueue,
loadFromTemplate,
sendToPrinter
}
}

View File

@@ -0,0 +1,87 @@
import type { Operation } from './usePrintQueue'
export interface PrintTemplate {
id: string
name: string
description?: string
operations: Operation[]
createdAt: number
updatedAt: number
}
const STORAGE_KEY = 'printercentral-templates'
export function useTemplates() {
const templates = useState<PrintTemplate[]>('templates', () => [])
const initialized = useState('templatesInitialized', () => false)
// Cargar de localStorage al iniciar (solo cliente)
if (import.meta.client && !initialized.value) {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
templates.value = JSON.parse(stored)
} catch (e) {
console.error('Error parsing templates:', e)
}
}
initialized.value = true
}
// Guardar en localStorage cuando cambie
watch(templates, (val) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
}
}, { deep: true })
function saveTemplate(name: string, description: string, operations: Operation[]): PrintTemplate {
const template: PrintTemplate = {
id: crypto.randomUUID(),
name,
description,
operations: JSON.parse(JSON.stringify(operations)),
createdAt: Date.now(),
updatedAt: Date.now()
}
templates.value = [...templates.value, template]
return template
}
function updateTemplate(id: string, updates: Partial<Omit<PrintTemplate, 'id' | 'createdAt'>>) {
const idx = templates.value.findIndex(t => t.id === id)
if (idx !== -1) {
templates.value = templates.value.map((t, i) =>
i === idx ? { ...t, ...updates, updatedAt: Date.now() } : t
)
}
}
function deleteTemplate(id: string) {
templates.value = templates.value.filter(t => t.id !== id)
}
function loadTemplate(id: string): Operation[] | null {
const template = templates.value.find(t => t.id === id)
return template ? JSON.parse(JSON.stringify(template.operations)) : null
}
function duplicateTemplate(id: string): PrintTemplate | null {
const template = templates.value.find(t => t.id === id)
if (!template) return null
return saveTemplate(
`${template.name} (copia)`,
template.description || '',
template.operations
)
}
return {
templates: readonly(templates),
saveTemplate,
updateTemplate,
deleteTemplate,
loadTemplate,
duplicateTemplate
}
}

60
app/pages/index.vue Normal file
View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
const activeTab = ref('constructor')
const isDesktop = useMediaQuery('(min-width: 768px)')
const queue = usePrintQueue()
</script>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Header -->
<LayoutAppHeader />
<!-- Layout principal -->
<div class="flex h-[calc(100vh-73px)]">
<!-- Sidebar solo en desktop -->
<aside
v-if="isDesktop"
class="w-80 border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex flex-col"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">Cola de Impresión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ queue.operations.value.length }} comandos
</p>
</div>
<div class="flex-1 overflow-y-auto p-4">
<QueuePrintQueue />
</div>
<div class="p-4 border-t border-gray-200 dark:border-gray-800">
<QueueQueueActions />
</div>
</aside>
<!-- Panel principal -->
<main class="flex-1 overflow-y-auto pb-mobile-nav md:pb-0">
<div class="max-w-3xl mx-auto p-4">
<!-- En mobile: mostrar según tab activo -->
<!-- En desktop: siempre mostrar constructor -->
<template v-if="isDesktop || activeTab === 'constructor'">
<ConstructorCommandBuilder />
<ConstructorQuickActions class="mt-4" />
</template>
<template v-else-if="activeTab === 'queue'">
<QueuePrintQueue />
<QueueQueueActions class="mt-4" />
</template>
<template v-else-if="activeTab === 'templates'">
<TemplatesTemplateList />
</template>
</div>
</main>
</div>
<!-- Navegación mobile -->
<LayoutMobileNavigation v-model="activeTab" />
</div>
</template>