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:
187
app/components/constructor/CommandBuilder.vue
Normal file
187
app/components/constructor/CommandBuilder.vue
Normal 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>
|
||||
54
app/components/constructor/QuickActions.vue
Normal file
54
app/components/constructor/QuickActions.vue
Normal 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>
|
||||
31
app/components/layout/AppHeader.vue
Normal file
31
app/components/layout/AppHeader.vue
Normal 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>
|
||||
40
app/components/layout/MobileNavigation.vue
Normal file
40
app/components/layout/MobileNavigation.vue
Normal 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>
|
||||
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>
|
||||
80
app/components/templates/TemplateCard.vue
Normal file
80
app/components/templates/TemplateCard.vue
Normal 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>
|
||||
53
app/components/templates/TemplateList.vue
Normal file
53
app/components/templates/TemplateList.vue
Normal 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>
|
||||
Reference in New Issue
Block a user