Files
seguidorDeLotes/nuxt4/app/components/lotes/TrazabilidadGraph.vue
josedario87 c4be9649b8
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m5s
mejoras UI 5
2025-11-22 02:19:32 -06:00

310 lines
9.6 KiB
Vue

<template>
<div class="space-y-4">
<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 dark:text-gray-400">Relaciones insumo resultado por nivel</p>
</div>
<div class="flex gap-2 items-center">
<UBadge color="blue" variant="subtle" v-if="nodes.length">
{{ nodes.length }} nodos · {{ edges.length }} aristas
</UBadge>
</div>
</div>
<div class="relative w-full overflow-x-auto rounded-xl border border-gray-200 dark:border-slate-800 bg-gradient-to-b" :class="bgGradient">
<svg
:width="svgWidth"
:height="svgHeight"
class="min-w-full"
:style="{ background: 'transparent' }"
>
<defs>
<filter id="nodeShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#0f172a" flood-opacity="0.08" />
</filter>
<linearGradient id="edgeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="edgeColor" stop-opacity="0.8" />
<stop offset="100%" :stop-color="edgeColor" stop-opacity="0.4" />
</linearGradient>
</defs>
<!-- Grid horizontal y vertical -->
<g stroke="#e2e8f0" stroke-width="1" :stroke-opacity="colorMode === 'dark' ? 0.1 : 0.35">
<line
v-for="depth in depthLines"
:key="`h-${depth}`"
:x1="0"
:x2="svgWidth"
:y1="paddingY + depth * levelGap"
:y2="paddingY + depth * levelGap"
stroke-dasharray="4 4"
/>
<line
v-for="x in verticalGrid"
:key="`v-${x}`"
:x1="x"
:x2="x"
y1="0"
:y2="svgHeight"
stroke-dasharray="6 6"
/>
</g>
<!-- Aristas con codo (vertical + horizontal) -->
<g stroke="url(#edgeGradient)" stroke-width="2.5" stroke-linecap="round">
<template v-for="edge in edges" :key="`${edge.from}-${edge.to}`">
<!-- tramo vertical -->
<line
:x1="edge.fromPos.x"
:y1="edge.fromPos.y"
:x2="edge.fromPos.x"
:y2="edge.midPos.y"
class="transition-opacity duration-200"
:opacity="0.9"
/>
<!-- tramo horizontal -->
<line
:x1="edge.fromPos.x"
:y1="edge.midPos.y"
:x2="edge.toPos.x"
:y2="edge.midPos.y"
class="transition-opacity duration-200"
:opacity="0.9"
/>
<!-- tramo final vertical -->
<line
:x1="edge.toPos.x"
:y1="edge.midPos.y"
:x2="edge.toPos.x"
:y2="edge.toPos.y"
class="transition-opacity duration-200"
:opacity="0.9"
/>
</template>
</g>
<!-- Nodos -->
<g>
<g
v-for="node in nodes"
:key="node.id"
class="transition-transform duration-200"
filter="url(#nodeShadow)"
>
<rect
:x="node.x - nodeBox.width / 2"
:y="node.y - nodeBox.height / 2"
:width="nodeBox.width"
:height="nodeBox.height"
rx="14"
:fill="getNodeFill(node.tipo)"
:stroke="node.depth === 0 ? accentColor : '#0F172A'"
:stroke-opacity="node.depth === 0 ? 0.6 : 0.05"
:stroke-width="node.depth === 0 ? 2 : 1"
/>
<text
:x="node.x"
:y="node.y - 10"
text-anchor="middle"
class="font-semibold"
:fill="textColor"
>
{{ node.label }}
</text>
<text
:x="node.x"
:y="node.y + 10"
text-anchor="middle"
class="text-[11px]"
:fill="subTextColor"
>
{{ getTipoLabel(node.tipo) }}
</text>
<text
v-if="node.cantidad_kg"
:x="node.x"
:y="node.y + 26"
text-anchor="middle"
class="text-[10px]"
:fill="subTextColor"
>
{{ node.cantidad_kg.toLocaleString('es-AR') }} kg
</text>
</g>
</g>
</svg>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<UBadge color="violet" variant="subtle">Uva</UBadge>
<UBadge color="green" variant="subtle">Despulpado</UBadge>
<UBadge color="orange" variant="subtle">Oreado/Presecado</UBadge>
<UBadge color="blue" variant="subtle">Reposo</UBadge>
<UBadge color="emerald" variant="subtle">Secado/Mezcla</UBadge>
</div>
</div>
</template>
<script setup lang="ts">
import { useColorMode } from '#imports'
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 }
const colorMode = useColorMode()
const bgGradient = computed(() => colorMode.value === 'dark'
? 'from-slate-900 to-slate-950'
: 'from-white to-slate-50')
const edgeColor = computed(() => colorMode.value === 'dark' ? '#94a3b8' : '#94a3b8')
const textColor = computed(() => colorMode.value === 'dark' ? '#e2e8f0' : '#0f172a')
const subTextColor = computed(() => colorMode.value === 'dark' ? '#cbd5e1' : '#334155')
const accentColor = computed(() => colorMode.value === 'dark' ? '#22d3ee' : '#0ea5e9')
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 }
midPos: { 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 },
midPos: { x: fromNode.x, y: (fromNode.y + toNode.y) / 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 depthLines = computed(() => {
if (!nodes.value.length) return []
const maxDepth = Math.max(...nodes.value.map((n) => n.depth), 0)
return Array.from({ length: maxDepth + 1 }, (_, i) => i)
})
const verticalGrid = computed(() => {
if (!nodes.value.length) return []
const xs = nodes.value.map((n) => n.x)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const step = 180
const coords: number[] = []
for (let x = minX - step; x <= maxX + step; x += step) {
coords.push(x)
}
return coords
})
const getNodeFill = (tipo: string): string => {
const palette: Record<string, string> = {
uva: colorMode.value === 'dark' ? '#4c1d95' : '#F3E8FF',
despulpado_primera: colorMode.value === 'dark' ? '#166534' : '#DCFCE7',
despulpado_segunda: colorMode.value === 'dark' ? '#854d0e' : '#FEF9C3',
despulpado_rechazos: colorMode.value === 'dark' ? '#7f1d1d' : '#FEE2E2',
oreado: colorMode.value === 'dark' ? '#9a3412' : '#FFEDD5',
presecado: colorMode.value === 'dark' ? '#92400e' : '#FDE68A',
reposo: colorMode.value === 'dark' ? '#1d4ed8' : '#DBEAFE',
secado: colorMode.value === 'dark' ? '#065f46' : '#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>