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:
47
app/components/queue/PrintQueue.vue
Normal file
47
app/components/queue/PrintQueue.vue
Normal 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>
|
||||
111
app/components/queue/QueueActions.vue
Normal file
111
app/components/queue/QueueActions.vue
Normal 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>
|
||||
183
app/components/queue/QueueItem.vue
Normal file
183
app/components/queue/QueueItem.vue
Normal 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>
|
||||
Reference in New Issue
Block a user