Implementar sistema completo de trazabilidad de lotes
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s
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:
@@ -6,85 +6,45 @@
|
||||
<UContainer class="py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold mb-2">Plantilla Nuxt + Authentik</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Ejemplo de integración con Authentik Proxy Outpost
|
||||
</p>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Seguidor de Lotes</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Sistema de trazabilidad de café
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<AuthUserAvatar v-if="isAuthenticated" />
|
||||
<AuthLogoutButton v-if="isAuthenticated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Componentes de autenticación -->
|
||||
<div v-if="isAuthenticated" class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Columna izquierda -->
|
||||
<div class="space-y-6">
|
||||
<!-- Avatar y datos básicos -->
|
||||
<AuthUserAvatar />
|
||||
|
||||
<!-- Botones de acción individuales -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Acciones de Sesión</h3>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<AuthSessionStatusButton />
|
||||
<AuthProfileButton />
|
||||
<AuthLogoutButton />
|
||||
<AuthLoginButton />
|
||||
<!-- Contenido principal -->
|
||||
<div v-if="isAuthenticated">
|
||||
<!-- Navegación por Tabs -->
|
||||
<UTabs v-model="selectedTab" :items="tabs" class="mb-6">
|
||||
<!-- Tab: Lotes -->
|
||||
<template #lotes>
|
||||
<div class="py-4">
|
||||
<LotesLotesTable
|
||||
@create="showCreateLoteModal = true"
|
||||
@view="handleViewLote"
|
||||
@edit="handleEditLote"
|
||||
@trazabilidad="handleViewTrazabilidad"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Verificaciones Frontend/Backend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-cpu-chip" class="w-5 h-5" />
|
||||
<h3 class="text-lg font-semibold">Verificación de Sistema</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<AuthFrontendVerificationButton />
|
||||
<AuthBackendVerificationButton />
|
||||
<!-- Tab: Operaciones -->
|
||||
<template #operaciones>
|
||||
<div class="py-4">
|
||||
<OperacionesOperacionesTable
|
||||
@create="showCreateOperacionModal = true"
|
||||
@view="handleViewOperacion"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha -->
|
||||
<div class="space-y-6">
|
||||
<!-- Metadatos completos -->
|
||||
<AuthUserMetadata />
|
||||
|
||||
<!-- Verificaciones de Grupos Frontend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-group" class="w-5 h-5 text-purple-500" />
|
||||
<h3 class="text-lg font-semibold">Grupos (Frontend)</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<AuthCheckAuthentikAdminsButton />
|
||||
<AuthCheckGrupoPruebaButton />
|
||||
<AuthCheckLvl0Button />
|
||||
<AuthCheckPublicAccessButton />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Verificaciones de Grupos Backend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-server-stack" class="w-5 h-5 text-orange-500" />
|
||||
<h3 class="text-lg font-semibold">Grupos (Backend)</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<AuthCheckAuthentikAdminsButton :verify-backend="true" />
|
||||
<AuthCheckGrupoPruebaButton :verify-backend="true" />
|
||||
<AuthCheckLvl0Button :verify-backend="true" />
|
||||
<AuthCheckPublicAccessButton :verify-backend="true" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no está autenticado -->
|
||||
@@ -99,14 +59,130 @@
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
|
||||
<!-- Modal: Crear/Editar Lote -->
|
||||
<UModal v-model="showLoteFormModal">
|
||||
<LotesLoteForm
|
||||
:lote="selectedLote"
|
||||
@cancel="closeLoteFormModal"
|
||||
@success="handleLoteFormSuccess"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Ver Detalle de Lote -->
|
||||
<UModal v-model="showLoteDetailModal">
|
||||
<LotesLoteCard
|
||||
v-if="selectedLote"
|
||||
:lote="selectedLote"
|
||||
@edit="handleEditLoteFromDetail"
|
||||
@trazabilidad="handleViewTrazabilidadFromDetail"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Ver Trazabilidad -->
|
||||
<UModal v-model="showTrazabilidadModal" :ui="{ width: 'max-w-4xl' }">
|
||||
<LotesTrazabilidadTree
|
||||
v-if="trazabilidadLoteId"
|
||||
:lote-id="trazabilidadLoteId"
|
||||
@close="showTrazabilidadModal = false"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Crear Operación -->
|
||||
<UModal v-model="showCreateOperacionModal" :ui="{ width: 'max-w-3xl' }">
|
||||
<OperacionesOperacionForm
|
||||
@cancel="showCreateOperacionModal = false"
|
||||
@success="handleOperacionFormSuccess"
|
||||
/>
|
||||
</UModal>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote, Operacion } from '~/composables/useLotes'
|
||||
|
||||
const { isAuthenticated } = useAuthentik()
|
||||
|
||||
// Navegación
|
||||
const selectedTab = ref(0)
|
||||
const tabs = [
|
||||
{ label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes' },
|
||||
{ label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones' },
|
||||
]
|
||||
|
||||
// Estados de modales
|
||||
const showLoteFormModal = ref(false)
|
||||
const showLoteDetailModal = ref(false)
|
||||
const showTrazabilidadModal = ref(false)
|
||||
const showCreateLoteModal = ref(false)
|
||||
const showCreateOperacionModal = ref(false)
|
||||
|
||||
// Estados de datos
|
||||
const selectedLote = ref<Lote | null>(null)
|
||||
const trazabilidadLoteId = ref<string | null>(null)
|
||||
|
||||
// Handlers para Lotes
|
||||
const handleViewLote = (lote: Lote) => {
|
||||
selectedLote.value = lote
|
||||
showLoteDetailModal.value = true
|
||||
}
|
||||
|
||||
const handleEditLote = (lote: Lote) => {
|
||||
selectedLote.value = lote
|
||||
showLoteFormModal.value = true
|
||||
}
|
||||
|
||||
const handleEditLoteFromDetail = () => {
|
||||
showLoteDetailModal.value = false
|
||||
showLoteFormModal.value = true
|
||||
}
|
||||
|
||||
const handleViewTrazabilidad = (lote: Lote) => {
|
||||
trazabilidadLoteId.value = lote.id
|
||||
showTrazabilidadModal.value = true
|
||||
}
|
||||
|
||||
const handleViewTrazabilidadFromDetail = () => {
|
||||
if (selectedLote.value) {
|
||||
showLoteDetailModal.value = false
|
||||
trazabilidadLoteId.value = selectedLote.value.id
|
||||
showTrazabilidadModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const closeLoteFormModal = () => {
|
||||
showLoteFormModal.value = false
|
||||
selectedLote.value = null
|
||||
}
|
||||
|
||||
const handleLoteFormSuccess = () => {
|
||||
closeLoteFormModal()
|
||||
// La tabla se recargará automáticamente
|
||||
}
|
||||
|
||||
// Handlers para Operaciones
|
||||
const handleViewOperacion = (operacion: Operacion) => {
|
||||
// TODO: Implementar vista de detalle de operación si es necesario
|
||||
console.log('Ver operación:', operacion)
|
||||
}
|
||||
|
||||
const handleOperacionFormSuccess = () => {
|
||||
showCreateOperacionModal.value = false
|
||||
// Las tablas se recargarán automáticamente
|
||||
}
|
||||
|
||||
// Watch para crear lote
|
||||
watch(showCreateLoteModal, (value) => {
|
||||
if (value) {
|
||||
selectedLote.value = null
|
||||
showLoteFormModal.value = true
|
||||
showCreateLoteModal.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Configurar meta tags para PWA
|
||||
useHead({
|
||||
title: 'Seguidor de Lotes - Trazabilidad de Café',
|
||||
link: [
|
||||
{ rel: 'manifest', href: '/manifest.webmanifest' },
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' },
|
||||
|
||||
108
nuxt4/app/components/lotes/LoteCard.vue
Normal file
108
nuxt4/app/components/lotes/LoteCard.vue
Normal 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>
|
||||
147
nuxt4/app/components/lotes/LoteForm.vue
Normal file
147
nuxt4/app/components/lotes/LoteForm.vue
Normal 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>
|
||||
169
nuxt4/app/components/lotes/LotesTable.vue
Normal file
169
nuxt4/app/components/lotes/LotesTable.vue
Normal 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>
|
||||
180
nuxt4/app/components/lotes/TrazabilidadTree.vue
Normal file
180
nuxt4/app/components/lotes/TrazabilidadTree.vue
Normal 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>
|
||||
289
nuxt4/app/components/operaciones/OperacionForm.vue
Normal file
289
nuxt4/app/components/operaciones/OperacionForm.vue
Normal 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>
|
||||
144
nuxt4/app/components/operaciones/OperacionesTable.vue
Normal file
144
nuxt4/app/components/operaciones/OperacionesTable.vue
Normal 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>
|
||||
393
nuxt4/app/composables/useLotes.ts
Normal file
393
nuxt4/app/composables/useLotes.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Composable para gestión de lotes y operaciones de trazabilidad
|
||||
*/
|
||||
|
||||
export interface Lote {
|
||||
id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
fecha_creado: string
|
||||
lugar_id: number | null
|
||||
cantidad_kg: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface Operacion {
|
||||
id: string
|
||||
tipo: string
|
||||
fecha: string
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface TrazabilidadRow {
|
||||
lote_id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
}
|
||||
|
||||
export const useLotes = () => {
|
||||
const toast = useToast()
|
||||
|
||||
// =====================================================
|
||||
// FUNCIONES PARA LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con filtros opcionales
|
||||
*/
|
||||
const fetchLotes = async (filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
try {
|
||||
const query = new URLSearchParams()
|
||||
if (filtros?.tipo) query.append('tipo', filtros.tipo)
|
||||
if (filtros?.limit) query.append('limit', filtros.limit.toString())
|
||||
if (filtros?.offset) query.append('offset', filtros.offset.toString())
|
||||
|
||||
const queryString = query.toString()
|
||||
const url = `/api/lotes${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote[]
|
||||
count: number
|
||||
}>(url)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo lotes')
|
||||
}
|
||||
|
||||
return data.value?.data || []
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching lotes:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo lotes',
|
||||
color: 'red',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su ID
|
||||
*/
|
||||
const fetchLoteById = async (id: string) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>(`/api/lotes/${id}`)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo lote')
|
||||
}
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo lote
|
||||
*/
|
||||
const createLote = async (loteData: {
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
}) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>('/api/lotes', {
|
||||
method: 'POST',
|
||||
body: loteData,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error creando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote creado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error creating lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error creando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un lote existente
|
||||
*/
|
||||
const updateLote = async (
|
||||
id: string,
|
||||
updates: Partial<{
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}>
|
||||
) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>(`/api/lotes/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error actualizando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote actualizado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error updating lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error actualizando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un lote
|
||||
*/
|
||||
const deleteLote = async (id: string) => {
|
||||
try {
|
||||
const { error } = await useFetch(`/api/lotes/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error eliminando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote eliminado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error eliminando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial completo de trazabilidad de un lote
|
||||
*/
|
||||
const fetchTrazabilidad = async (id: string) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: {
|
||||
historial: TrazabilidadRow[]
|
||||
estadisticas: {
|
||||
total_ancestros: number
|
||||
profundidad_maxima: number
|
||||
kg_iniciales: number
|
||||
}
|
||||
}
|
||||
}>(`/api/lotes/${id}/trazabilidad`)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo trazabilidad')
|
||||
}
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching trazabilidad:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo trazabilidad',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// FUNCIONES PARA OPERACIONES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las operaciones con filtros opcionales
|
||||
*/
|
||||
const fetchOperaciones = async (filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
try {
|
||||
const query = new URLSearchParams()
|
||||
if (filtros?.tipo) query.append('tipo', filtros.tipo)
|
||||
if (filtros?.limit) query.append('limit', filtros.limit.toString())
|
||||
if (filtros?.offset) query.append('offset', filtros.offset.toString())
|
||||
|
||||
const queryString = query.toString()
|
||||
const url = `/api/operaciones${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Operacion[]
|
||||
count: number
|
||||
}>(url)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo operaciones')
|
||||
}
|
||||
|
||||
return data.value?.data || []
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching operaciones:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo operaciones',
|
||||
color: 'red',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva operación con sus lotes inputs/outputs
|
||||
*/
|
||||
const createOperacion = async (operacionData: {
|
||||
tipo: string
|
||||
fecha?: string
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
inputs: Array<{ lote_id: string; cantidad_kg?: number }>
|
||||
outputs: Array<{
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
meta?: Record<string, any>
|
||||
}>
|
||||
}) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: {
|
||||
operacion: Operacion
|
||||
lotes_creados: Lote[]
|
||||
}
|
||||
}>('/api/operaciones', {
|
||||
method: 'POST',
|
||||
body: operacionData,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error creando operación')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Operación creada correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error creating operacion:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error creando operación',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CONSTANTES ÚTILES
|
||||
// =====================================================
|
||||
|
||||
const TIPOS_LOTE = [
|
||||
{ value: 'uva', label: 'Uva' },
|
||||
{ value: 'despulpado_primera', label: 'Despulpado Primera' },
|
||||
{ value: 'despulpado_segunda', label: 'Despulpado Segunda' },
|
||||
{ value: 'despulpado_rechazos', label: 'Despulpado Rechazos' },
|
||||
{ value: 'oreado', label: 'Oreado' },
|
||||
{ value: 'presecado', label: 'Presecado' },
|
||||
{ value: 'reposo', label: 'Reposo' },
|
||||
{ value: 'secado', label: 'Secado' },
|
||||
]
|
||||
|
||||
const TIPOS_OPERACION = [
|
||||
{ value: 'ingreso', label: 'Ingreso', icon: 'i-heroicons-arrow-down-tray' },
|
||||
{ value: 'despulpado', label: 'Despulpado', icon: 'i-heroicons-beaker' },
|
||||
{ value: 'oreado', label: 'Oreado', icon: 'i-heroicons-sun' },
|
||||
{ value: 'presecado', label: 'Presecado', icon: 'i-heroicons-fire' },
|
||||
{ value: 'reposo', label: 'Reposo', icon: 'i-heroicons-pause' },
|
||||
{ value: 'secado', label: 'Secado', icon: 'i-heroicons-check-circle' },
|
||||
{ value: 'traslado', label: 'Traslado', icon: 'i-heroicons-arrow-right' },
|
||||
{ value: 'mezcla', label: 'Mezcla', icon: 'i-heroicons-beaker' },
|
||||
{ value: 'ajuste_merma', label: 'Ajuste Merma', icon: 'i-heroicons-adjustments-horizontal' },
|
||||
{ value: 'ajuste_cantidad', label: 'Ajuste Cantidad', icon: 'i-heroicons-calculator' },
|
||||
{ value: 'ajuste_tipo', label: 'Ajuste Tipo', icon: 'i-heroicons-pencil' },
|
||||
]
|
||||
|
||||
return {
|
||||
// Lotes
|
||||
fetchLotes,
|
||||
fetchLoteById,
|
||||
createLote,
|
||||
updateLote,
|
||||
deleteLote,
|
||||
fetchTrazabilidad,
|
||||
|
||||
// Operaciones
|
||||
fetchOperaciones,
|
||||
createOperacion,
|
||||
|
||||
// Constantes
|
||||
TIPOS_LOTE,
|
||||
TIPOS_OPERACION,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user