diff --git a/frontend/package.json b/frontend/package.json index 3d66d87..50ce482 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,8 +18,6 @@ "vite": "^7.3.1" }, "dependencies": { - "@types/d3": "^7.4.3", - "d3": "^7.9.0", "framer-motion": "^12.34.0", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ba8d2f..64a604f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,12 +14,15 @@ import { useWebSocket } from "./hooks/useWebSocket"; import { useRotation } from "./hooks/useRotation"; import type { WSMessage } from "./types"; -const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"]; +const VIEW_NAMES = ["Topología de Red", "Proyectos", "Calendario"]; function LoadingScreen({ label }: { label: string }) { return (
-

{label}

+
+
+

{label}

+
); } @@ -57,14 +60,14 @@ function App() { {topology.data ? ( ) : ( - + )}
{tasks.data ? ( ) : ( - + )}
diff --git a/frontend/src/components/Calendar/CalendarView.tsx b/frontend/src/components/Calendar/CalendarView.tsx index e27c3e9..a99629f 100644 --- a/frontend/src/components/Calendar/CalendarView.tsx +++ b/frontend/src/components/Calendar/CalendarView.tsx @@ -24,53 +24,88 @@ export function CalendarView({ events }: CalendarViewProps) { }); }; + const hasEvents = events.length > 0; + return ( -
-
-

- Hoy — {formatDate(today)} -

- {todayEvents.length === 0 ? ( -

Sin eventos programados

+
+ {/* Summary bar */} +
+ + {events.length} eventos esta + semana + +
+ +
+ {!hasEvents ? ( +
+ 📅 +

+ Sin eventos programados +

+

+ Los próximos eventos del calendario de Odoo aparecerán aquí +

+
) : ( -
- {todayEvents.map((e) => ( - - ))} +
+ {/* Today */} +
+

Hoy

+

{formatDate(today)}

+ {todayEvents.length === 0 ? ( +

Sin eventos

+ ) : ( +
+ {todayEvents.map((e) => ( + + ))} +
+ )} +
+ + {/* Tomorrow */} +
+

Mañana

+

+ {formatDate(tomorrow)} +

+ {tomorrowEvents.length === 0 ? ( +

Sin eventos

+ ) : ( +
+ {tomorrowEvents.map((e) => ( + + ))} +
+ )} +
+ + {/* This week */} +
+

Esta semana

+

Próximos días

+ {laterEvents.length === 0 ? ( +

Sin eventos

+ ) : ( +
+ {laterEvents.map((e) => ( +
+ + {formatDate(e.start)} + + {e.name} +
+ ))} +
+ )} +
)} -
- -
-

- Mañana — {formatDate(tomorrow)} -

- {tomorrowEvents.length === 0 ? ( -

Sin eventos programados

- ) : ( -
- {tomorrowEvents.map((e) => ( - - ))} -
- )} -
- - {laterEvents.length > 0 && ( -
-

Esta semana

-
- {laterEvents.map((e) => ( -
- - {formatDate(e.start)} - - {e.name} -
- ))} -
-
- )} +
); } diff --git a/frontend/src/components/Layout/Header.tsx b/frontend/src/components/Layout/Header.tsx index d39a490..2537411 100644 --- a/frontend/src/components/Layout/Header.tsx +++ b/frontend/src/components/Layout/Header.tsx @@ -27,18 +27,25 @@ export function Header({ viewName, connected }: HeaderProps) { }); return ( -
+
-

Dashboard

- {viewName} +

+ Consultoria AS +

+ | + {viewName}
-
+
{dateStr} - {timeStr} - + {timeStr} +
+ + + {connected ? "Conectado" : "Sin conexión"} + +
); diff --git a/frontend/src/components/Tasks/KanbanBoard.tsx b/frontend/src/components/Tasks/KanbanBoard.tsx index 498ec12..74cd545 100644 --- a/frontend/src/components/Tasks/KanbanBoard.tsx +++ b/frontend/src/components/Tasks/KanbanBoard.tsx @@ -1,58 +1,106 @@ -import { TaskCard } from "./TaskCard"; import type { Project } from "../../types"; interface KanbanBoardProps { projects: Project[]; } -export function KanbanBoard({ projects }: KanbanBoardProps) { - const allStages = new Set(); - for (const project of projects) { - for (const stage of Object.keys(project.stages)) { - allStages.add(stage); - } - } - const stageList = Array.from(allStages); +const STAGE_COLORS = [ + "bg-blue-500", + "bg-emerald-500", + "bg-amber-500", + "bg-purple-500", + "bg-rose-500", + "bg-cyan-500", + "bg-orange-500", + "bg-teal-500", +]; + +function ProjectRow({ project }: { project: Project }) { + const stages = Object.entries(project.stages); + const totalTasks = stages.reduce((sum, [, tasks]) => sum + tasks.length, 0); + + if (totalTasks === 0) return null; return ( -
-
-
- Proyecto -
- {stageList.map((stage) => ( -
- {stage} -
- ))} +
+ {/* Project header */} +
+

+ {project.name} +

+ + {totalTasks} tareas +
-
- {projects.map((project) => ( -
+ {stages.map(([stageName, tasks], i) => { + const pct = (tasks.length / totalTasks) * 100; + if (pct < 1) return null; + return ( +
+ ); + })} +
+ + {/* Stage chips */} +
+ {stages.map(([stageName, tasks], i) => ( + -
- {project.name} -
- {stageList.map((stage) => ( -
- {(project.stages[stage] || []).map((task) => ( - - ))} -
- ))} -
+ + {stageName} + {tasks.length} + ))}
); } + +export function KanbanBoard({ projects }: KanbanBoardProps) { + const activeProjects = projects.filter((p) => { + const total = Object.values(p.stages).reduce((s, t) => s + t.length, 0); + return total > 0; + }); + + const totalTasks = activeProjects.reduce( + (sum, p) => sum + Object.values(p.stages).reduce((s, t) => s + t.length, 0), + 0 + ); + + return ( +
+ {/* Summary bar */} +
+ + {activeProjects.length} proyectos + activos + + | + + {totalTasks} tareas totales + +
+ + {/* Projects grid */} +
+
+ {activeProjects.map((project) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Topology/NetworkGraph.tsx b/frontend/src/components/Topology/NetworkGraph.tsx index c408b0f..9e1a08a 100644 --- a/frontend/src/components/Topology/NetworkGraph.tsx +++ b/frontend/src/components/Topology/NetworkGraph.tsx @@ -1,5 +1,3 @@ -import { useEffect, useRef, useState } from "react"; -import * as d3 from "d3"; import { NodeCard } from "./NodeCard"; import type { NetworkNode } from "../../types"; @@ -7,105 +5,86 @@ interface NetworkGraphProps { nodes: NetworkNode[]; } -interface SimNode extends d3.SimulationNodeDatum { - id: string; - data: NetworkNode; -} +const CATEGORY_ORDER: [string, string[]][] = [ + ["Infraestructura", ["firewall", "router", "switch", "ap"]], + ["Servidores", ["server"]], + ["Almacenamiento", ["nas"]], + ["Equipos", ["pc"]], + ["Periféricos", ["printer", "phone", "camera"]], + ["Otros", ["device"]], +]; -interface SimLink extends d3.SimulationLinkDatum { - source: SimNode; - target: SimNode; +function categorizeNodes(nodes: NetworkNode[]) { + const categorized: { label: string; nodes: NetworkNode[] }[] = []; + const assigned = new Set(); + + for (const [label, icons] of CATEGORY_ORDER) { + const matching = nodes.filter( + (n) => icons.includes(n.icon) && !assigned.has(n.ip) + ); + if (matching.length > 0) { + categorized.push({ label, nodes: matching }); + for (const n of matching) assigned.add(n.ip); + } + } + + // Any remaining nodes + const remaining = nodes.filter((n) => !assigned.has(n.ip)); + if (remaining.length > 0) { + categorized.push({ label: "Otros", nodes: remaining }); + } + + return categorized; } export function NetworkGraph({ nodes }: NetworkGraphProps) { - const svgRef = useRef(null); - const [positions, setPositions] = useState>(new Map()); - - useEffect(() => { - if (!svgRef.current || nodes.length === 0) return; - - const width = 3840; - const height = 1900; - - const simNodes: SimNode[] = nodes.map((n) => ({ - id: n.ip, - data: n, - x: width / 2 + (Math.random() - 0.5) * 800, - y: height / 2 + (Math.random() - 0.5) * 600, - })); - - const nodeMap = new Map(simNodes.map((n) => [n.data.name, n])); - - const simLinks: SimLink[] = []; - for (const node of nodes) { - for (const connName of node.connections || []) { - const target = nodeMap.get(connName); - const source = nodeMap.get(node.name); - if (source && target) { - simLinks.push({ source, target }); - } - } - } - - const sim = d3 - .forceSimulation(simNodes) - .force( - "link", - d3.forceLink(simLinks).id((d: d3.SimulationNodeDatum) => (d as SimNode).id).distance(350) - ) - .force("charge", d3.forceManyBody().strength(-2000)) - .force("center", d3.forceCenter(width / 2, height / 2)) - .force("collision", d3.forceCollide(150)) - .on("tick", () => { - // Update link positions via D3 - const svg = d3.select(svgRef.current); - svg - .selectAll("line.link") - .data(simLinks) - .join("line") - .attr("class", "link") - .attr("x1", (d) => d.source.x!) - .attr("y1", (d) => d.source.y!) - .attr("x2", (d) => d.target.x!) - .attr("y2", (d) => d.target.y!) - .attr("stroke", "#2a2a3e") - .attr("stroke-width", 3); - - // Update React state for node positions - const newPositions = new Map(); - for (const n of simNodes) { - newPositions.set(n.id, { x: n.x!, y: n.y! }); - } - setPositions(newPositions); - }); - - return () => { - sim.stop(); - }; - }, [nodes]); - + const categories = categorizeNodes(nodes); const onlineCount = nodes.filter((n) => n.status === "up").length; - const offlineCount = nodes.filter((n) => n.status === "down").length; + const total = nodes.length; return (
- - {nodes.map((node) => { - const pos = positions.get(node.ip); - if (!pos) return null; - return ( - - ); - })} - -
- - {onlineCount} online - - - {offlineCount} offline + {/* Summary bar */} +
+
+ + + {onlineCount} online + +
+
+ + + {total - onlineCount} offline + +
+ | + + {total} dispositivos
+ + {/* Scrollable grid */} +
+ {categories.map((cat) => ( +
+

+ {cat.label} + ({cat.nodes.length}) +

+
+ {cat.nodes.map((node) => ( + + ))} +
+
+ ))} +
); } diff --git a/frontend/src/components/Topology/NodeCard.tsx b/frontend/src/components/Topology/NodeCard.tsx index 1861cc2..f97deea 100644 --- a/frontend/src/components/Topology/NodeCard.tsx +++ b/frontend/src/components/Topology/NodeCard.tsx @@ -2,63 +2,70 @@ import { useState } from "react"; import type { NetworkNode } from "../../types"; const ICON_MAP: Record = { - router: "\uD83C\uDF10", - firewall: "\uD83D\uDEE1\uFE0F", - server: "\uD83D\uDDA5\uFE0F", - switch: "\uD83D\uDD00", - ap: "\uD83D\uDCE1", - pc: "\uD83D\uDCBB", - nas: "\uD83D\uDCBE", - printer: "\uD83D\uDDA8\uFE0F", - phone: "\uD83D\uDCDE", - camera: "\uD83D\uDCF7", - device: "\uD83D\uDCF1", + router: "🌐", + firewall: "🛡️", + server: "🖥️", + switch: "🔀", + ap: "📡", + pc: "💻", + nas: "💾", + printer: "🖨️", + phone: "📞", + camera: "📷", + device: "📱", }; interface NodeCardProps { node: NetworkNode; - x: number; - y: number; } -export function NodeCard({ node, x, y }: NodeCardProps) { - const [showPassword, setShowPassword] = useState(false); +export function NodeCard({ node }: NodeCardProps) { + const [expanded, setExpanded] = useState(false); - const statusColor = - node.status === "up" - ? "bg-success" - : node.status === "down" - ? "bg-danger" - : "bg-warning"; + const isUp = node.status === "up"; + const statusBg = isUp ? "bg-success-dim" : "bg-danger-dim"; + const statusBorder = isUp ? "border-success/30" : "border-danger/30"; + const statusDot = isUp ? "bg-success" : "bg-danger"; return ( - -
setShowPassword((prev) => !prev)} - > - {ICON_MAP[node.icon] || "\uD83D\uDCE6"} - - {node.name} - - {node.ip} - {node.username && ( - - {node.username} / {showPassword ? node.password : "\u2022\u2022\u2022\u2022"} - - )} - {node.public_url && ( - - {node.public_url} - - )} -
- - - {node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"} - +
setExpanded((prev) => !prev)} + > +
+ + {ICON_MAP[node.icon] || "📦"} +
+

+ {node.name} +

+

{node.ip}

+ {node.public_url && ( + + {node.public_url.replace(/^https?:\/\//, "")} + + )}
- + + {expanded && (node.username || node.public_url) && ( +
+ {node.username && ( +

+ User: {node.username} + {node.password && ( + + Pass:{" "} + {node.password} + + )} +

+ )} + {node.public_url && ( +

{node.public_url}

+ )} +
+ )} +
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 9a62e0b..50f4045 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,16 +1,23 @@ @import "tailwindcss"; @theme { - --color-bg-primary: #0a0a0f; - --color-bg-secondary: #12121a; - --color-bg-card: #1a1a2e; - --color-border: #2a2a3e; - --color-text-primary: #e4e4e7; - --color-text-secondary: #a1a1aa; - --color-accent: #3b82f6; - --color-success: #22c55e; - --color-danger: #ef4444; - --color-warning: #f59e0b; + --color-bg-primary: #0b0d14; + --color-bg-secondary: #111520; + --color-bg-card: #181d2a; + --color-bg-card-hover: #1f2638; + --color-border: #252d3f; + --color-border-light: #2e3750; + --color-text-primary: #e8eaf0; + --color-text-secondary: #8892a8; + --color-text-muted: #5c6478; + --color-accent: #4f8ff7; + --color-accent-dim: #2a4a80; + --color-success: #34d399; + --color-success-dim: #0d3d2e; + --color-danger: #f87171; + --color-danger-dim: #3d1515; + --color-warning: #fbbf24; + --color-warning-dim: #3d2e0a; } html { @@ -20,9 +27,21 @@ html { body { background-color: var(--color-bg-primary); color: var(--color-text-primary); - font-family: "Inter", system-ui, sans-serif; + font-family: "Inter", system-ui, -apple-system, sans-serif; margin: 0; overflow: hidden; width: 3840px; height: 2160px; } + +/* Smooth scrollbar for overflow areas */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +}