This commit is contained in:
@@ -38,7 +38,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Árbol de Trazabilidad -->
|
<!-- Grafo de Trazabilidad -->
|
||||||
|
<TrazabilidadGraph v-if="trazabilidad" :historial="trazabilidad.historial" />
|
||||||
|
|
||||||
|
<!-- Árbol de Trazabilidad (texto) -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h4 class="font-semibold text-lg">Historial</h4>
|
<h4 class="font-semibold text-lg">Historial</h4>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@@ -107,6 +110,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import TrazabilidadGraph from './TrazabilidadGraph.vue'
|
||||||
import type { TrazabilidadRow } from '~/composables/useLotes'
|
import type { TrazabilidadRow } from '~/composables/useLotes'
|
||||||
|
|
||||||
const props = defineProps<{
|
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_id: string | null
|
||||||
operacion_tipo: string | null
|
operacion_tipo: string | null
|
||||||
profundidad: number
|
profundidad: number
|
||||||
|
parent_lote_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLotes = () => {
|
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
|
// Obtener trazabilidad completa
|
||||||
const trazabilidad = await getTrazabilidad(id)
|
const trazabilidad = await getTrazabilidad(id)
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ RETURNS TABLE (
|
|||||||
cantidad_kg NUMERIC,
|
cantidad_kg NUMERIC,
|
||||||
operacion_id UUID,
|
operacion_id UUID,
|
||||||
operacion_tipo TEXT,
|
operacion_tipo TEXT,
|
||||||
profundidad INTEGER
|
profundidad INTEGER,
|
||||||
|
parent_lote_id UUID
|
||||||
) AS $$
|
) AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
@@ -147,7 +148,8 @@ BEGIN
|
|||||||
l.cantidad_kg,
|
l.cantidad_kg,
|
||||||
ol.operacion_id,
|
ol.operacion_id,
|
||||||
o.tipo AS operacion_tipo,
|
o.tipo AS operacion_tipo,
|
||||||
0 AS profundidad
|
0 AS profundidad,
|
||||||
|
NULL::UUID AS parent_lote_id
|
||||||
FROM lotes l
|
FROM lotes l
|
||||||
LEFT JOIN operacion_lotes ol ON ol.lote_id = l.id AND ol.rol = 'output'
|
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
|
LEFT JOIN operaciones o ON o.id = ol.operacion_id
|
||||||
@@ -163,7 +165,8 @@ BEGIN
|
|||||||
l2.cantidad_kg,
|
l2.cantidad_kg,
|
||||||
ol2.operacion_id,
|
ol2.operacion_id,
|
||||||
o2.tipo AS operacion_tipo,
|
o2.tipo AS operacion_tipo,
|
||||||
t.profundidad + 1
|
t.profundidad + 1,
|
||||||
|
t.lote_id AS parent_lote_id
|
||||||
FROM trazabilidad t
|
FROM trazabilidad t
|
||||||
JOIN operacion_lotes ol_in
|
JOIN operacion_lotes ol_in
|
||||||
ON ol_in.operacion_id = t.operacion_id
|
ON ol_in.operacion_id = t.operacion_id
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface TrazabilidadRow {
|
|||||||
operacion_id: string | null
|
operacion_id: string | null
|
||||||
operacion_tipo: string | null
|
operacion_tipo: string | null
|
||||||
profundidad: number
|
profundidad: number
|
||||||
|
parent_lote_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoteConOrigen extends Lote {
|
export interface LoteConOrigen extends Lote {
|
||||||
|
|||||||
Reference in New Issue
Block a user