feat: Agregar visualizador de preview para templates

- Nuevo composable usePreview.ts para procesar operaciones en líneas de preview
- Nuevo componente PaperSimulator.vue que simula el papel térmico
- Nuevo modal PreviewModal.vue para vista previa con edición inline de variables
- Botón "Vista previa" agregado a TemplateCard.vue
- Integración del modal en TemplateList.vue
This commit is contained in:
2025-11-26 17:29:10 -06:00
parent 3105e83038
commit 413ec6d27e
5 changed files with 750 additions and 8 deletions

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import type { PreviewLine, TextSegment, FontType } from '~/composables/usePreview'
import { CHAR_LIMITS } from '~/composables/usePreview'
const props = defineProps<{
lines: PreviewLine[]
font: FontType
}>()
const emit = defineEmits<{
'edit-variable': [variableName: string, currentValue: string]
}>()
// Ancho del papel según la fuente
const paperWidth = computed(() => {
return props.font === 'font_b' ? '40ch' : '33ch'
})
// Obtener clases CSS para una línea
function getLineClasses(line: PreviewLine): string[] {
const classes: string[] = []
if (line.bold) classes.push('font-bold')
if (line.underline) classes.push('underline')
if (line.exceedsLimit) classes.push('line-exceeds')
if (line.doubleWidth) classes.push('text-double-width')
if (line.doubleHeight) classes.push('text-double-height')
// Alineación
if (line.align === 'center') classes.push('text-center')
else if (line.align === 'right') classes.push('text-right')
else classes.push('text-left')
return classes
}
// Manejar click en variable
function handleVariableClick(segment: TextSegment) {
if (segment.type === 'variable' && segment.variableName) {
emit('edit-variable', segment.variableName, segment.content)
}
}
</script>
<template>
<div
class="paper-simulator"
:style="{ width: paperWidth }"
>
<template v-for="(line, index) in lines" :key="index">
<!-- Línea vacía (feed) -->
<div v-if="line.isFeed" class="line-feed">&nbsp;</div>
<!-- Línea con contenido -->
<div
v-else
class="line"
:class="getLineClasses(line)"
>
<template v-for="(segment, segIndex) in line.segments" :key="segIndex">
<!-- Segmento de variable (clickeable) -->
<span
v-if="segment.type === 'variable'"
class="variable"
@click="handleVariableClick(segment)"
:title="`Variable: ${segment.variableName} (click para editar)`"
>{{ segment.content }}</span>
<!-- Segmento de texto normal -->
<span v-else>{{ segment.content }}</span>
</template>
</div>
</template>
<!-- Indicador de papel vacío -->
<div v-if="lines.length === 0" class="empty-paper">
<span class="text-gray-400">Vista previa vacía</span>
</div>
</div>
</template>
<style scoped>
/* Contenedor del papel - simula papel térmico */
.paper-simulator {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.4;
background: linear-gradient(to bottom, #fefefe 0%, #f8f8f8 100%);
border: 1px solid #ddd;
border-radius: 4px;
padding: 16px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.1),
inset 0 0 20px rgba(0, 0, 0, 0.02);
/* Simular textura de papel */
background-image:
linear-gradient(to bottom, #fefefe 0%, #f8f8f8 100%),
repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(0, 0, 0, 0.01) 1px,
rgba(0, 0, 0, 0.01) 2px
);
}
/* Cada línea del ticket */
.line {
white-space: pre; /* Preservar espacios */
min-height: 1.4em;
color: #1a1a1a;
}
/* Línea vacía (feed) */
.line-feed {
height: 1.4em;
}
/* Doble ancho - escalar horizontalmente */
.text-double-width {
transform: scaleX(2);
transform-origin: left;
display: inline-block;
}
/* Doble alto - escalar verticalmente */
.text-double-height {
transform: scaleY(1.5);
transform-origin: top;
line-height: 2;
}
/* Línea que excede el límite */
.line-exceeds {
background: rgba(239, 68, 68, 0.15);
position: relative;
}
.line-exceeds::after {
content: '⚠';
position: absolute;
right: -20px;
color: #ef4444;
font-size: 12px;
}
/* Variable editable - fondo azul suave */
.variable {
background: rgba(59, 130, 246, 0.2);
cursor: pointer;
border-radius: 2px;
transition: background 0.15s ease;
}
.variable:hover {
background: rgba(59, 130, 246, 0.4);
}
/* Papel vacío */
.empty-paper {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
font-style: italic;
}
/* Scrollbar estilizado */
.paper-simulator::-webkit-scrollbar {
width: 6px;
}
.paper-simulator::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.paper-simulator::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
.paper-simulator::-webkit-scrollbar-thumb:hover {
background: #aaa;
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { PrintTemplate } from '~/composables/useTemplates'
import type { Operation } from '~/composables/usePrintQueue'
import { usePreview, CHAR_LIMITS } from '~/composables/usePreview'
const props = defineProps<{
template: PrintTemplate | null
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'print': [operations: Operation[], variables: Record<string, string>]
}>()
const { previewState, variableValues, generatePreview, updateVariable, extractVariables } = usePreview()
// Estado local para edición de variables
const editingVariable = ref<string | null>(null)
const editingValue = ref('')
const editInputRef = ref<HTMLInputElement | null>(null)
// Variables extraídas del template
const variables = computed(() => {
if (!props.template) return []
return extractVariables(props.template.operations)
})
// Inicializar preview cuando se abre el modal
watch(() => props.modelValue, (isOpen) => {
if (isOpen && props.template) {
// Inicializar valores de variables con defaults
const initialValues: Record<string, string> = {}
for (const v of variables.value) {
initialValues[v.name] = v.defaultValue || v.name
}
generatePreview(props.template.operations, initialValues)
}
}, { immediate: true })
// Manejar click en variable para editar
function handleEditVariable(variableName: string, currentValue: string) {
editingVariable.value = variableName
editingValue.value = currentValue
// Focus en el input después de renderizar
nextTick(() => {
editInputRef.value?.focus()
editInputRef.value?.select()
})
}
// Confirmar edición de variable
function confirmEdit() {
if (editingVariable.value && props.template) {
updateVariable(editingVariable.value, editingValue.value, props.template.operations)
}
editingVariable.value = null
}
// Cancelar edición
function cancelEdit() {
editingVariable.value = null
}
// Manejar teclas en el input
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
confirmEdit()
} else if (event.key === 'Escape') {
cancelEdit()
}
}
// Cerrar modal
function closeModal() {
emit('update:modelValue', false)
}
// Imprimir
function handlePrint() {
if (props.template) {
emit('print', props.template.operations, { ...variableValues.value })
}
closeModal()
}
// Info del preview
const previewInfo = computed(() => {
if (!previewState.value) return null
const limits = CHAR_LIMITS[previewState.value.font]
return {
font: previewState.value.font === 'font_a' ? 'Font A' : 'Font B',
chars: limits.normal,
lines: previewState.value.totalLines,
warnings: previewState.value.warnings.length
}
})
</script>
<template>
<UModal
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:ui="{
width: 'max-w-2xl'
}"
>
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-eye" class="text-primary-500" />
<span class="font-medium">Vista Previa</span>
<span v-if="template" class="text-gray-500">- {{ template.name }}</span>
</div>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Editor de variable inline (aparece sobre el paper) -->
<div
v-if="editingVariable"
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3"
>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-blue-700 dark:text-blue-300">
{{ editingVariable }}:
</label>
<input
ref="editInputRef"
v-model="editingValue"
type="text"
class="flex-1 px-2 py-1 text-sm border border-blue-300 dark:border-blue-600 rounded bg-white dark:bg-gray-800 font-mono"
@keydown="handleKeydown"
@blur="confirmEdit"
/>
<UButton size="xs" color="primary" @click="confirmEdit">
<UIcon name="i-heroicons-check" />
</UButton>
<UButton size="xs" variant="ghost" @click="cancelEdit">
<UIcon name="i-heroicons-x-mark" />
</UButton>
</div>
</div>
<!-- Simulador de papel -->
<div class="flex justify-center">
<PreviewPaperSimulator
v-if="previewState"
:lines="previewState.lines"
:font="previewState.font"
@edit-variable="handleEditVariable"
/>
</div>
<!-- Barra de información -->
<div
v-if="previewInfo"
class="flex items-center justify-center gap-4 text-sm text-gray-500 dark:text-gray-400"
>
<span class="flex items-center gap-1">
<UIcon name="i-heroicons-document-text" class="w-4 h-4" />
{{ previewInfo.font }} ({{ previewInfo.chars }} chars)
</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="flex items-center gap-1">
<UIcon name="i-heroicons-bars-3" class="w-4 h-4" />
{{ previewInfo.lines }} líneas
</span>
<template v-if="previewInfo.warnings > 0">
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="flex items-center gap-1 text-amber-500">
<UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4" />
{{ previewInfo.warnings }} {{ previewInfo.warnings === 1 ? 'advertencia' : 'advertencias' }}
</span>
</template>
<template v-else>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="flex items-center gap-1 text-green-500">
<UIcon name="i-heroicons-check-circle" class="w-4 h-4" />
OK
</span>
</template>
</div>
<!-- Lista de warnings -->
<div
v-if="previewState?.warnings.length"
class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"
>
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-amber-700 dark:text-amber-300">Advertencias:</p>
<ul class="mt-1 space-y-1 text-amber-600 dark:text-amber-400">
<li v-for="(warning, i) in previewState.warnings" :key="i">
{{ warning }}
</li>
</ul>
</div>
</div>
</div>
<!-- Tip de uso -->
<p class="text-xs text-center text-gray-400 dark:text-gray-500">
<UIcon name="i-heroicons-light-bulb" class="w-3 h-3 inline" />
Click en los valores resaltados en azul para editarlos
</p>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="closeModal">
Cancelar
</UButton>
<UButton
color="primary"
icon="i-heroicons-printer"
@click="handlePrint"
:disabled="!previewState || previewState.lines.length === 0"
>
Imprimir
</UButton>
</div>
</template>
</UModal>
</template>