veamo
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m6s

This commit is contained in:
2025-11-22 01:10:37 -06:00
parent 2500cb1181
commit 0bdb5013f7
6 changed files with 254 additions and 4 deletions

View File

@@ -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<{

View 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>

View File

@@ -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 = () => {