Implementar sistema completo de trazabilidad de lotes
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s

- Agregar PostgreSQL 16 con esquema completo
- Crear API endpoints para lotes y operaciones
- Implementar UI con Nuxt UI (tablas, formularios, trazabilidad)
- Agregar datos de ejemplo del flujo completo
- Documentar sistema en PLAN_TRAZABILIDAD.md
This commit is contained in:
2025-11-21 18:39:04 -06:00
parent e5456bf522
commit ee3dffa38e
26 changed files with 4223 additions and 74 deletions

View File

@@ -0,0 +1,108 @@
<template>
<UCard>
<template #header>
<div class="flex justify-between items-center">
<div>
<h3 class="text-xl font-bold">{{ lote.codigo || 'Sin código' }}</h3>
<UBadge :color="getTipoColor(lote.tipo)" variant="subtle" class="mt-1">
{{ getTipoLabel(lote.tipo) }}
</UBadge>
</div>
<div class="flex gap-2">
<UButton
icon="i-heroicons-pencil"
variant="outline"
size="sm"
label="Editar"
@click="$emit('edit')"
/>
<UButton
icon="i-heroicons-chart-bar"
variant="outline"
size="sm"
color="green"
label="Ver Trazabilidad"
@click="$emit('trazabilidad')"
/>
</div>
</div>
</template>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500">ID</p>
<p class="font-mono text-xs">{{ lote.id }}</p>
</div>
<div>
<p class="text-sm text-gray-500">Fecha de Creación</p>
<p class="font-medium">{{ formatDate(lote.fecha_creado) }}</p>
</div>
<div>
<p class="text-sm text-gray-500">Cantidad</p>
<p class="font-medium text-lg">
{{ lote.cantidad_kg ? `${lote.cantidad_kg.toLocaleString('es-AR')} kg` : '-' }}
</p>
</div>
<div>
<p class="text-sm text-gray-500">Lugar ID</p>
<p class="font-medium">{{ lote.lugar_id || '-' }}</p>
</div>
</div>
<div v-if="lote.meta" class="pt-4 border-t">
<p class="text-sm text-gray-500 mb-2">Información Adicional</p>
<UCard class="bg-gray-50">
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(lote.meta, null, 2) }}</pre>
</UCard>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { Lote } from '~/composables/useLotes'
const props = defineProps<{
lote: Lote
}>()
const emit = defineEmits<{
edit: []
trazabilidad: []
}>()
const { TIPOS_LOTE } = useLotes()
const getTipoLabel = (tipo: string) => {
const found = TIPOS_LOTE.find((t) => t.value === tipo)
return found?.label || tipo
}
const getTipoColor = (tipo: string): string => {
const colorMap: Record<string, string> = {
uva: 'purple',
despulpado_primera: 'green',
despulpado_segunda: 'yellow',
despulpado_rechazos: 'red',
oreado: 'orange',
presecado: 'amber',
reposo: 'blue',
secado: 'emerald',
}
return colorMap[tipo] || 'gray'
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
</script>

View File

@@ -0,0 +1,147 @@
<template>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">
{{ lote ? 'Editar Lote' : 'Nuevo Lote' }}
</h3>
</template>
<UForm :state="formState" @submit="handleSubmit" class="space-y-4">
<UFormGroup label="Código" name="codigo" help="Opcional - Si no se especifica, se generará automáticamente">
<UInput
v-model="formState.codigo"
placeholder="Ej: UVA-001, SEC-042"
/>
</UFormGroup>
<UFormGroup label="Tipo" name="tipo" required>
<USelect
v-model="formState.tipo"
:options="TIPOS_LOTE"
placeholder="Selecciona un tipo"
/>
</UFormGroup>
<UFormGroup label="Cantidad (kg)" name="cantidad_kg">
<UInput
v-model.number="formState.cantidad_kg"
type="number"
step="0.01"
placeholder="0.00"
/>
</UFormGroup>
<UFormGroup label="Lugar ID" name="lugar_id" help="Opcional - ID del lugar donde se encuentra">
<UInput
v-model.number="formState.lugar_id"
type="number"
placeholder="1"
/>
</UFormGroup>
<UFormGroup label="Información Adicional (JSON)" name="meta" help="Opcional - Datos adicionales en formato JSON">
<UTextarea
v-model="metaText"
placeholder='{"humedad": 12.5, "notas": "café especial"}'
rows="3"
/>
</UFormGroup>
<div class="flex gap-2 justify-end">
<UButton
type="button"
variant="outline"
label="Cancelar"
@click="$emit('cancel')"
/>
<UButton
type="submit"
:loading="loading"
:label="lote ? 'Actualizar' : 'Crear'"
/>
</div>
</UForm>
</UCard>
</template>
<script setup lang="ts">
import type { Lote } from '~/composables/useLotes'
const props = defineProps<{
lote?: Lote | null
}>()
const emit = defineEmits<{
cancel: []
success: [lote: Lote]
}>()
const { createLote, updateLote, TIPOS_LOTE } = useLotes()
const loading = ref(false)
const metaText = ref('')
const formState = ref({
codigo: '',
tipo: '',
cantidad_kg: undefined as number | undefined,
lugar_id: undefined as number | undefined,
})
// Si hay un lote para editar, cargar sus datos
watchEffect(() => {
if (props.lote) {
formState.value = {
codigo: props.lote.codigo || '',
tipo: props.lote.tipo,
cantidad_kg: props.lote.cantidad_kg || undefined,
lugar_id: props.lote.lugar_id || undefined,
}
metaText.value = props.lote.meta ? JSON.stringify(props.lote.meta, null, 2) : ''
}
})
const handleSubmit = async () => {
loading.value = true
try {
// Parsear meta si existe
let meta = null
if (metaText.value.trim()) {
try {
meta = JSON.parse(metaText.value)
} catch (err) {
useToast().add({
title: 'Error',
description: 'El formato JSON de información adicional no es válido',
color: 'red',
})
return
}
}
const data = {
codigo: formState.value.codigo || undefined,
tipo: formState.value.tipo,
cantidad_kg: formState.value.cantidad_kg,
lugar_id: formState.value.lugar_id,
meta,
}
let result: Lote | null = null
if (props.lote) {
// Actualizar lote existente
result = await updateLote(props.lote.id, data)
} else {
// Crear nuevo lote
result = await createLote(data)
}
if (result) {
emit('success', result)
}
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold">Lotes</h2>
<p class="text-gray-500">Gestión de lotes de café</p>
</div>
<UButton
icon="i-heroicons-plus"
label="Nuevo Lote"
@click="$emit('create')"
/>
</div>
<div class="flex gap-2">
<USelect
v-model="filtroTipo"
:options="[{ value: '', label: 'Todos los tipos' }, ...TIPOS_LOTE]"
placeholder="Filtrar por tipo"
class="w-64"
/>
<UButton
icon="i-heroicons-arrow-path"
label="Refrescar"
variant="outline"
@click="loadLotes"
/>
</div>
<UCard>
<UTable
v-model="selected"
:rows="lotes"
:columns="columns"
:loading="loading"
:empty-state="{
icon: 'i-heroicons-inbox',
label: 'No hay lotes registrados'
}"
@select="handleSelect"
>
<template #codigo-data="{ row }">
<span class="font-mono font-semibold">{{ row.codigo || '-' }}</span>
</template>
<template #tipo-data="{ row }">
<UBadge :color="getTipoColor(row.tipo)" variant="subtle">
{{ getTipoLabel(row.tipo) }}
</UBadge>
</template>
<template #cantidad_kg-data="{ row }">
<span v-if="row.cantidad_kg" class="font-medium">
{{ row.cantidad_kg.toLocaleString('es-AR') }} kg
</span>
<span v-else class="text-gray-400">-</span>
</template>
<template #fecha_creado-data="{ row }">
{{ formatDate(row.fecha_creado) }}
</template>
<template #actions-data="{ row }">
<div class="flex gap-1">
<UButton
icon="i-heroicons-eye"
size="xs"
variant="ghost"
@click="$emit('view', row)"
/>
<UButton
icon="i-heroicons-pencil"
size="xs"
variant="ghost"
@click="$emit('edit', row)"
/>
<UButton
icon="i-heroicons-chart-bar"
size="xs"
variant="ghost"
color="green"
@click="$emit('trazabilidad', row)"
/>
</div>
</template>
</UTable>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { Lote } from '~/composables/useLotes'
const emit = defineEmits<{
create: []
view: [lote: Lote]
edit: [lote: Lote]
trazabilidad: [lote: Lote]
}>()
const { fetchLotes, TIPOS_LOTE } = useLotes()
const lotes = ref<Lote[]>([])
const loading = ref(false)
const selected = ref<Lote[]>([])
const filtroTipo = ref('')
const columns = [
{ key: 'codigo', label: 'Código' },
{ key: 'tipo', label: 'Tipo' },
{ key: 'cantidad_kg', label: 'Cantidad' },
{ key: 'fecha_creado', label: 'Fecha Creación' },
{ key: 'actions', label: 'Acciones' },
]
const loadLotes = async () => {
loading.value = true
try {
lotes.value = await fetchLotes({
tipo: filtroTipo.value || undefined,
})
} finally {
loading.value = false
}
}
const handleSelect = (rows: Lote[]) => {
selected.value = rows
}
const getTipoLabel = (tipo: string) => {
const found = TIPOS_LOTE.find((t) => t.value === tipo)
return found?.label || tipo
}
const getTipoColor = (tipo: string): string => {
const colorMap: Record<string, string> = {
uva: 'purple',
despulpado_primera: 'green',
despulpado_segunda: 'yellow',
despulpado_rechazos: 'red',
oreado: 'orange',
presecado: 'amber',
reposo: 'blue',
secado: 'emerald',
}
return colorMap[tipo] || 'gray'
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Cargar lotes al montar
onMounted(() => {
loadLotes()
})
// Recargar cuando cambia el filtro
watch(filtroTipo, () => {
loadLotes()
})
</script>

View File

@@ -0,0 +1,180 @@
<template>
<UCard>
<template #header>
<div class="flex justify-between items-center">
<div>
<h3 class="text-xl font-bold">Trazabilidad de Lote</h3>
<p class="text-sm text-gray-500">Historial completo desde los ingresos iniciales</p>
</div>
<UButton
icon="i-heroicons-x-mark"
variant="ghost"
size="sm"
@click="$emit('close')"
/>
</div>
</template>
<div v-if="loading" class="flex justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="animate-spin w-8 h-8" />
</div>
<div v-else-if="trazabilidad" class="space-y-6">
<!-- Estadísticas -->
<div class="grid grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
<div class="text-center">
<p class="text-2xl font-bold text-blue-600">{{ trazabilidad.estadisticas.total_ancestros }}</p>
<p class="text-sm text-gray-600">Lotes ancestros</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-green-600">{{ trazabilidad.estadisticas.profundidad_maxima }}</p>
<p class="text-sm text-gray-600">Niveles de profundidad</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-orange-600">
{{ trazabilidad.estadisticas.kg_iniciales.toLocaleString('es-AR') }} kg
</p>
<p class="text-sm text-gray-600">Kilos iniciales</p>
</div>
</div>
<!-- Árbol de Trazabilidad -->
<div class="space-y-2">
<h4 class="font-semibold text-lg">Historial</h4>
<div class="space-y-1">
<div
v-for="(item, index) in sortedHistorial"
:key="index"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
:class="{
'bg-blue-50': item.profundidad === 0,
'border-l-4 border-blue-500': item.profundidad === 0,
}"
>
<!-- Indicador de profundidad -->
<div class="flex items-center gap-2 min-w-[100px]">
<UBadge :color="getProfundidadColor(item.profundidad)" variant="subtle" size="xs">
Nivel {{ item.profundidad }}
</UBadge>
</div>
<!-- Indentación visual -->
<div class="flex items-center">
<div :style="{ width: `${item.profundidad * 20}px` }" class="border-l-2 border-gray-300"></div>
<UIcon
v-if="item.profundidad > 0"
name="i-heroicons-arrow-turn-down-right"
class="w-4 h-4 text-gray-400 mx-1"
/>
</div>
<!-- Información del lote -->
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-mono font-semibold">{{ item.codigo || item.lote_id.substring(0, 8) }}</span>
<UBadge :color="getTipoColor(item.tipo)" variant="subtle">
{{ getTipoLabel(item.tipo) }}
</UBadge>
<span v-if="item.cantidad_kg" class="text-sm text-gray-600">
{{ item.cantidad_kg.toLocaleString('es-AR') }} kg
</span>
</div>
<!-- Operación que generó este lote -->
<div v-if="item.operacion_tipo" class="mt-1 text-sm text-gray-500">
<UIcon name="i-heroicons-beaker" class="w-3 h-3 inline" />
Generado por: <span class="font-medium">{{ getOperacionLabel(item.operacion_tipo) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Leyenda -->
<div class="pt-4 border-t">
<p class="text-xs text-gray-500">
<UIcon name="i-heroicons-information-circle" class="w-4 h-4 inline" />
Los lotes se muestran desde el más reciente (nivel 0) hasta los ingresos iniciales.
La profundidad indica cuántos pasos atrás se encuentra cada lote en la cadena de trazabilidad.
</p>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
No se encontró información de trazabilidad
</div>
</UCard>
</template>
<script setup lang="ts">
import type { TrazabilidadRow } from '~/composables/useLotes'
const props = defineProps<{
loteId: string
}>()
const emit = defineEmits<{
close: []
}>()
const { fetchTrazabilidad, TIPOS_LOTE, TIPOS_OPERACION } = useLotes()
const loading = ref(false)
const trazabilidad = ref<{
historial: TrazabilidadRow[]
estadisticas: {
total_ancestros: number
profundidad_maxima: number
kg_iniciales: number
}
} | null>(null)
const sortedHistorial = computed(() => {
if (!trazabilidad.value) return []
return [...trazabilidad.value.historial].sort((a, b) => a.profundidad - b.profundidad)
})
const loadTrazabilidad = async () => {
loading.value = true
try {
trazabilidad.value = await fetchTrazabilidad(props.loteId)
} finally {
loading.value = false
}
}
const getTipoLabel = (tipo: string) => {
const found = TIPOS_LOTE.find((t) => t.value === tipo)
return found?.label || tipo
}
const getTipoColor = (tipo: string): string => {
const colorMap: Record<string, string> = {
uva: 'purple',
despulpado_primera: 'green',
despulpado_segunda: 'yellow',
despulpado_rechazos: 'red',
oreado: 'orange',
presecado: 'amber',
reposo: 'blue',
secado: 'emerald',
}
return colorMap[tipo] || 'gray'
}
const getOperacionLabel = (tipo: string) => {
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
return found?.label || tipo
}
const getProfundidadColor = (profundidad: number): string => {
if (profundidad === 0) return 'blue'
if (profundidad <= 2) return 'green'
if (profundidad <= 4) return 'orange'
return 'red'
}
onMounted(() => {
loadTrazabilidad()
})
</script>

View File

@@ -0,0 +1,289 @@
<template>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Nueva Operación</h3>
</template>
<div class="space-y-6">
<!-- Paso 1: Tipo de Operación -->
<div v-if="step === 1" class="space-y-4">
<h4 class="font-medium">Paso 1: Selecciona el tipo de operación</h4>
<div class="grid grid-cols-2 gap-3">
<button
v-for="tipo in TIPOS_OPERACION"
:key="tipo.value"
@click="formState.tipo = tipo.value"
class="p-4 border-2 rounded-lg transition-all hover:border-primary"
:class="{
'border-primary bg-primary/10': formState.tipo === tipo.value,
'border-gray-200': formState.tipo !== tipo.value,
}"
>
<div class="flex items-center gap-3">
<UIcon :name="tipo.icon" class="w-6 h-6" />
<span class="font-medium">{{ tipo.label }}</span>
</div>
</button>
</div>
<div class="flex gap-2 justify-end pt-4">
<UButton
label="Cancelar"
variant="outline"
@click="$emit('cancel')"
/>
<UButton
label="Siguiente"
:disabled="!formState.tipo"
@click="step = 2"
/>
</div>
</div>
<!-- Paso 2: Seleccionar Lotes de Entrada (Inputs) -->
<div v-else-if="step === 2" class="space-y-4">
<h4 class="font-medium">Paso 2: Selecciona los lotes de entrada (inputs)</h4>
<p class="text-sm text-gray-500">
Estos son los lotes que se usarán en la operación de <strong>{{ getTipoLabel(formState.tipo) }}</strong>
</p>
<div v-if="loadingLotes" class="text-center py-4">
<UIcon name="i-heroicons-arrow-path" class="animate-spin w-6 h-6" />
</div>
<div v-else class="space-y-2">
<div
v-for="lote in lotesDisponibles"
:key="lote.id"
class="p-3 border rounded-lg cursor-pointer transition-all hover:border-primary"
:class="{
'border-primary bg-primary/10': isLoteSeleccionado(lote.id),
'border-gray-200': !isLoteSeleccionado(lote.id),
}"
@click="toggleLoteInput(lote)"
>
<div class="flex items-center justify-between">
<div>
<span class="font-mono font-semibold">{{ lote.codigo || lote.id.substring(0, 8) }}</span>
<UBadge :color="getTipoColor(lote.tipo)" variant="subtle" class="ml-2">
{{ getTipoLabel(lote.tipo) }}
</UBadge>
<span v-if="lote.cantidad_kg" class="ml-2 text-sm text-gray-600">
{{ lote.cantidad_kg.toLocaleString('es-AR') }} kg
</span>
</div>
<UIcon
v-if="isLoteSeleccionado(lote.id)"
name="i-heroicons-check-circle"
class="w-5 h-5 text-primary"
/>
</div>
</div>
</div>
<div class="flex gap-2 justify-end pt-4">
<UButton
label="Anterior"
variant="outline"
@click="step = 1"
/>
<UButton
label="Siguiente"
:disabled="formState.inputs.length === 0"
@click="step = 3"
/>
</div>
</div>
<!-- Paso 3: Definir Lotes de Salida (Outputs) -->
<div v-else-if="step === 3" class="space-y-4">
<h4 class="font-medium">Paso 3: Define los lotes de salida (outputs)</h4>
<p class="text-sm text-gray-500">
Estos son los lotes nuevos que se crearán como resultado de la operación
</p>
<div class="space-y-3">
<div
v-for="(output, index) in formState.outputs"
:key="index"
class="p-4 border rounded-lg space-y-3"
>
<div class="flex justify-between items-center">
<h5 class="font-medium">Lote de salida {{ index + 1 }}</h5>
<UButton
icon="i-heroicons-trash"
size="xs"
variant="ghost"
color="red"
@click="removeOutput(index)"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<UFormGroup label="Código (opcional)">
<UInput v-model="output.codigo" placeholder="Ej: PRIM-001" />
</UFormGroup>
<UFormGroup label="Tipo" required>
<USelect v-model="output.tipo" :options="TIPOS_LOTE" placeholder="Selecciona tipo" />
</UFormGroup>
<UFormGroup label="Cantidad (kg)">
<UInput v-model.number="output.cantidad_kg" type="number" step="0.01" placeholder="0.00" />
</UFormGroup>
</div>
</div>
</div>
<UButton
icon="i-heroicons-plus"
label="Agregar lote de salida"
variant="outline"
block
@click="addOutput"
/>
<div class="flex gap-2 justify-end pt-4">
<UButton
label="Anterior"
variant="outline"
@click="step = 2"
/>
<UButton
label="Crear Operación"
:loading="loading"
:disabled="formState.outputs.length === 0 || !allOutputsValid"
@click="handleSubmit"
/>
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { Lote } from '~/composables/useLotes'
const emit = defineEmits<{
cancel: []
success: []
}>()
const { fetchLotes, createOperacion, TIPOS_OPERACION, TIPOS_LOTE } = useLotes()
const step = ref(1)
const loading = ref(false)
const loadingLotes = ref(false)
const lotesDisponibles = ref<Lote[]>([])
const formState = ref({
tipo: '',
inputs: [] as Array<{ lote_id: string; cantidad_kg?: number; _lote?: Lote }>,
outputs: [] as Array<{ codigo?: string; tipo: string; cantidad_kg?: number }>,
})
const allOutputsValid = computed(() => {
return formState.value.outputs.every((output) => output.tipo)
})
const loadLotes = async () => {
loadingLotes.value = true
try {
lotesDisponibles.value = await fetchLotes()
} finally {
loadingLotes.value = false
}
}
const isLoteSeleccionado = (loteId: string) => {
return formState.value.inputs.some((input) => input.lote_id === loteId)
}
const toggleLoteInput = (lote: Lote) => {
const index = formState.value.inputs.findIndex((input) => input.lote_id === lote.id)
if (index >= 0) {
formState.value.inputs.splice(index, 1)
} else {
formState.value.inputs.push({
lote_id: lote.id,
cantidad_kg: lote.cantidad_kg || undefined,
_lote: lote,
})
}
}
const addOutput = () => {
formState.value.outputs.push({
codigo: '',
tipo: '',
cantidad_kg: undefined,
})
}
const removeOutput = (index: number) => {
formState.value.outputs.splice(index, 1)
}
const getTipoLabel = (tipo: string) => {
const foundOp = TIPOS_OPERACION.find((t) => t.value === tipo)
if (foundOp) return foundOp.label
const foundLote = TIPOS_LOTE.find((t) => t.value === tipo)
return foundLote?.label || tipo
}
const getTipoColor = (tipo: string): string => {
const colorMap: Record<string, string> = {
uva: 'purple',
despulpado_primera: 'green',
despulpado_segunda: 'yellow',
despulpado_rechazos: 'red',
oreado: 'orange',
presecado: 'amber',
reposo: 'blue',
secado: 'emerald',
}
return colorMap[tipo] || 'gray'
}
const handleSubmit = async () => {
loading.value = true
try {
const result = await createOperacion({
tipo: formState.value.tipo,
inputs: formState.value.inputs.map((input) => ({
lote_id: input.lote_id,
cantidad_kg: input.cantidad_kg,
})),
outputs: formState.value.outputs.map((output) => ({
codigo: output.codigo || undefined,
tipo: output.tipo,
cantidad_kg: output.cantidad_kg,
})),
})
if (result) {
emit('success')
}
} finally {
loading.value = false
}
}
// Cargar lotes cuando se monta el componente
onMounted(() => {
loadLotes()
})
// Agregar un output por defecto al inicio
watch(
() => step.value,
(newStep) => {
if (newStep === 3 && formState.value.outputs.length === 0) {
addOutput()
}
}
)
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold">Operaciones</h2>
<p class="text-gray-500">Historial de operaciones de proceso</p>
</div>
<UButton
icon="i-heroicons-plus"
label="Nueva Operación"
@click="$emit('create')"
/>
</div>
<div class="flex gap-2">
<USelect
v-model="filtroTipo"
:options="[{ value: '', label: 'Todos los tipos' }, ...TIPOS_OPERACION]"
placeholder="Filtrar por tipo"
class="w-64"
/>
<UButton
icon="i-heroicons-arrow-path"
label="Refrescar"
variant="outline"
@click="loadOperaciones"
/>
</div>
<UCard>
<UTable
:rows="operaciones"
:columns="columns"
:loading="loading"
:empty-state="{
icon: 'i-heroicons-inbox',
label: 'No hay operaciones registradas'
}"
>
<template #tipo-data="{ row }">
<div class="flex items-center gap-2">
<UIcon :name="getTipoIcon(row.tipo)" class="w-4 h-4" />
<UBadge :color="getTipoColor(row.tipo)" variant="subtle">
{{ getTipoLabel(row.tipo) }}
</UBadge>
</div>
</template>
<template #fecha-data="{ row }">
{{ formatDate(row.fecha) }}
</template>
<template #actions-data="{ row }">
<div class="flex gap-1">
<UButton
icon="i-heroicons-eye"
size="xs"
variant="ghost"
@click="$emit('view', row)"
/>
</div>
</template>
</UTable>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { Operacion } from '~/composables/useLotes'
const emit = defineEmits<{
create: []
view: [operacion: Operacion]
}>()
const { fetchOperaciones, TIPOS_OPERACION } = useLotes()
const operaciones = ref<Operacion[]>([])
const loading = ref(false)
const filtroTipo = ref('')
const columns = [
{ key: 'tipo', label: 'Tipo de Operación' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'lugar_id', label: 'Lugar' },
{ key: 'actions', label: 'Acciones' },
]
const loadOperaciones = async () => {
loading.value = true
try {
operaciones.value = await fetchOperaciones({
tipo: filtroTipo.value || undefined,
})
} finally {
loading.value = false
}
}
const getTipoLabel = (tipo: string) => {
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
return found?.label || tipo
}
const getTipoIcon = (tipo: string): string => {
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
return found?.icon || 'i-heroicons-cube'
}
const getTipoColor = (tipo: string): string => {
const colorMap: Record<string, string> = {
ingreso: 'blue',
despulpado: 'green',
oreado: 'orange',
presecado: 'amber',
reposo: 'purple',
secado: 'emerald',
traslado: 'gray',
mezcla: 'yellow',
ajuste_merma: 'red',
ajuste_cantidad: 'orange',
ajuste_tipo: 'yellow',
}
return colorMap[tipo] || 'gray'
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
onMounted(() => {
loadOperaciones()
})
watch(filtroTipo, () => {
loadOperaciones()
})
</script>