This commit is contained in:
@@ -38,7 +38,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Árbol de Trazabilidad -->
|
||||
<!-- Grafo de Trazabilidad -->
|
||||
<TrazabilidadGraph v-if="trazabilidad" :historial="trazabilidad.historial" />
|
||||
|
||||
<!-- Árbol de Trazabilidad (texto) -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-lg">Historial</h4>
|
||||
<div class="space-y-1">
|
||||
@@ -107,6 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TrazabilidadGraph from './TrazabilidadGraph.vue'
|
||||
import type { TrazabilidadRow } from '~/composables/useLotes'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
233
nuxt4/app/components/lotes/TrazabilidadGraph.vue
Normal file
233
nuxt4/app/components/lotes/TrazabilidadGraph.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold">Grafo de trazabilidad</h4>
|
||||
<p class="text-sm text-gray-500">Relaciones insumo → resultado por nivel de profundidad</p>
|
||||
</div>
|
||||
<UBadge color="blue" variant="subtle" v-if="nodes.length">
|
||||
{{ nodes.length }} nodos · {{ edges.length }} aristas
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
:width="svgWidth"
|
||||
:height="svgHeight"
|
||||
class="min-w-full bg-gradient-to-b from-white to-gray-50 rounded-lg border"
|
||||
>
|
||||
<!-- Aristas -->
|
||||
<g stroke="#CBD5E1" stroke-width="2">
|
||||
<line
|
||||
v-for="edge in edges"
|
||||
:key="`${edge.from}-${edge.to}`"
|
||||
:x1="edge.fromPos.x"
|
||||
:y1="edge.fromPos.y"
|
||||
:x2="edge.toPos.x"
|
||||
:y2="edge.toPos.y"
|
||||
stroke-linecap="round"
|
||||
class="transition-opacity duration-200"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Nodos -->
|
||||
<g>
|
||||
<g
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="transition-transform duration-200"
|
||||
>
|
||||
<rect
|
||||
:x="node.x - nodeBox.width / 2"
|
||||
:y="node.y - nodeBox.height / 2"
|
||||
:width="nodeBox.width"
|
||||
:height="nodeBox.height"
|
||||
rx="12"
|
||||
:fill="getNodeFill(node.tipo)"
|
||||
stroke="#0F172A"
|
||||
stroke-opacity="0.05"
|
||||
/>
|
||||
<text
|
||||
:x="node.x"
|
||||
:y="node.y - 10"
|
||||
text-anchor="middle"
|
||||
class="font-medium"
|
||||
fill="#0F172A"
|
||||
>
|
||||
{{ node.label }}
|
||||
</text>
|
||||
<text
|
||||
:x="node.x"
|
||||
:y="node.y + 10"
|
||||
text-anchor="middle"
|
||||
class="text-[11px]"
|
||||
fill="#334155"
|
||||
>
|
||||
{{ getTipoLabel(node.tipo) }}
|
||||
</text>
|
||||
<text
|
||||
v-if="node.cantidad_kg"
|
||||
:x="node.x"
|
||||
:y="node.y + 26"
|
||||
text-anchor="middle"
|
||||
class="text-[10px]"
|
||||
fill="#475569"
|
||||
>
|
||||
{{ node.cantidad_kg.toLocaleString('es-AR') }} kg
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-600">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-sm" :style="{ background: getNodeFill('uva') }"></span> Uva
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-sm" :style="{ background: getNodeFill('despulpado_primera') }"></span> Despulpado
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-sm" :style="{ background: getNodeFill('oreado') }"></span> Oreado / Presecado
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-sm" :style="{ background: getNodeFill('reposo') }"></span> Reposo
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-sm" :style="{ background: getNodeFill('secado') }"></span> Secado / mezcla
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TrazabilidadRow } from '~/composables/useLotes'
|
||||
|
||||
const props = defineProps<{
|
||||
historial: TrazabilidadRow[]
|
||||
}>()
|
||||
|
||||
const paddingX = 80
|
||||
const paddingY = 60
|
||||
const levelGap = 140
|
||||
const nodeBox = { width: 180, height: 64 }
|
||||
|
||||
type GraphNode = {
|
||||
id: string
|
||||
label: string
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
depth: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const groupedByDepth = computed(() => {
|
||||
const levels = new Map<number, TrazabilidadRow[]>()
|
||||
for (const row of props.historial) {
|
||||
const arr = levels.get(row.profundidad) || []
|
||||
arr.push(row)
|
||||
levels.set(row.profundidad, arr)
|
||||
}
|
||||
return levels
|
||||
})
|
||||
|
||||
const nodes = computed<GraphNode[]>(() => {
|
||||
const result: GraphNode[] = []
|
||||
const levels = groupedByDepth.value
|
||||
if (levels.size === 0) return result
|
||||
const maxCount = Math.max(...Array.from(levels.values()).map((l) => l.length))
|
||||
|
||||
for (const [depth, rows] of Array.from(levels.entries()).sort((a, b) => a[0] - b[0])) {
|
||||
const count = rows.length
|
||||
const width = Math.max(maxCount * nodeBox.width * 1.2, 400)
|
||||
const spacing = width / (count + 1)
|
||||
rows.forEach((row, idx) => {
|
||||
result.push({
|
||||
id: row.lote_id,
|
||||
label: row.codigo || row.lote_id.slice(0, 8),
|
||||
tipo: row.tipo,
|
||||
cantidad_kg: row.cantidad_kg,
|
||||
depth,
|
||||
x: paddingX + spacing * (idx + 1),
|
||||
y: paddingY + depth * levelGap,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const nodeById = computed(() => {
|
||||
const map = new Map<string, GraphNode>()
|
||||
for (const n of nodes.value) {
|
||||
map.set(n.id, n)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const edges = computed(() => {
|
||||
const list: {
|
||||
from: string
|
||||
to: string
|
||||
fromPos: { x: number; y: number }
|
||||
toPos: { x: number; y: number }
|
||||
}[] = []
|
||||
|
||||
for (const row of props.historial) {
|
||||
if (!row.parent_lote_id) continue
|
||||
const fromNode = nodeById.value.get(row.parent_lote_id)
|
||||
const toNode = nodeById.value.get(row.lote_id)
|
||||
if (fromNode && toNode) {
|
||||
list.push({
|
||||
from: fromNode.id,
|
||||
to: toNode.id,
|
||||
fromPos: { x: fromNode.x, y: fromNode.y + nodeBox.height / 2 },
|
||||
toPos: { x: toNode.x, y: toNode.y - nodeBox.height / 2 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const svgWidth = computed(() => {
|
||||
if (!nodes.value.length) return 600
|
||||
const maxX = Math.max(...nodes.value.map((n) => n.x + nodeBox.width / 2), 600)
|
||||
return maxX + paddingX
|
||||
})
|
||||
|
||||
const svgHeight = computed(() => {
|
||||
if (!nodes.value.length) return paddingY * 2 + nodeBox.height
|
||||
const maxDepth = Math.max(...nodes.value.map((n) => n.depth), 0)
|
||||
return paddingY * 2 + maxDepth * levelGap + nodeBox.height
|
||||
})
|
||||
|
||||
const getNodeFill = (tipo: string): string => {
|
||||
const palette: Record<string, string> = {
|
||||
uva: '#F3E8FF',
|
||||
despulpado_primera: '#DCFCE7',
|
||||
despulpado_segunda: '#FEF9C3',
|
||||
despulpado_rechazos: '#FEE2E2',
|
||||
oreado: '#FFEDD5',
|
||||
presecado: '#FDE68A',
|
||||
reposo: '#DBEAFE',
|
||||
secado: '#D1FAE5',
|
||||
}
|
||||
return palette[tipo] || '#E2E8F0'
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const map: Record<string, string> = {
|
||||
uva: 'Uva',
|
||||
despulpado_primera: 'Despulpado 1ra',
|
||||
despulpado_segunda: 'Despulpado 2da',
|
||||
despulpado_rechazos: 'Rechazos',
|
||||
oreado: 'Oreado',
|
||||
presecado: 'Presecado',
|
||||
reposo: 'Reposo',
|
||||
secado: 'Secado',
|
||||
}
|
||||
return map[tipo] || tipo
|
||||
}
|
||||
</script>
|
||||
@@ -28,6 +28,7 @@ export interface TrazabilidadRow {
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
parent_lote_id: string | null
|
||||
}
|
||||
|
||||
export const useLotes = () => {
|
||||
|
||||
@@ -18,6 +18,14 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isUuid = /^[0-9a-fA-F-]{36}$/.test(id)
|
||||
if (!isUuid) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de lote inválido',
|
||||
})
|
||||
}
|
||||
|
||||
// Obtener trazabilidad completa
|
||||
const trazabilidad = await getTrazabilidad(id)
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ RETURNS TABLE (
|
||||
cantidad_kg NUMERIC,
|
||||
operacion_id UUID,
|
||||
operacion_tipo TEXT,
|
||||
profundidad INTEGER
|
||||
profundidad INTEGER,
|
||||
parent_lote_id UUID
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
@@ -147,7 +148,8 @@ BEGIN
|
||||
l.cantidad_kg,
|
||||
ol.operacion_id,
|
||||
o.tipo AS operacion_tipo,
|
||||
0 AS profundidad
|
||||
0 AS profundidad,
|
||||
NULL::UUID AS parent_lote_id
|
||||
FROM lotes l
|
||||
LEFT JOIN operacion_lotes ol ON ol.lote_id = l.id AND ol.rol = 'output'
|
||||
LEFT JOIN operaciones o ON o.id = ol.operacion_id
|
||||
@@ -163,7 +165,8 @@ BEGIN
|
||||
l2.cantidad_kg,
|
||||
ol2.operacion_id,
|
||||
o2.tipo AS operacion_tipo,
|
||||
t.profundidad + 1
|
||||
t.profundidad + 1,
|
||||
t.lote_id AS parent_lote_id
|
||||
FROM trazabilidad t
|
||||
JOIN operacion_lotes ol_in
|
||||
ON ol_in.operacion_id = t.operacion_id
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface TrazabilidadRow {
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
parent_lote_id: string | null
|
||||
}
|
||||
|
||||
export interface LoteConOrigen extends Lote {
|
||||
|
||||
Reference in New Issue
Block a user