All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m6s
234 lines
6.6 KiB
Vue
234 lines
6.6 KiB
Vue
<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>
|