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:
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>
|
||||
Reference in New Issue
Block a user