Setup technology ecosystem page with network graph (#2)
* Add ecosystem page with network graph and company info panel Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com> * Add unpaid company highlighting to network graph with toggle Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com> * fix id something * remove the useless files * update the graph * update the graph,fix the api ,also add some style and filters * Refactor process impacts chart to use new CustomBarChart component (#3) Co-authored-by: Cursor Agent <cursoragent@cursor.com> * fix somestyle , add charts in ecosystem --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
699548c674
commit
40b5ad6e3c
169
app/components/ecosystem/info-panel.tsx
Normal file
169
app/components/ecosystem/info-panel.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
|
||||
import apiService from "~/lib/api";
|
||||
|
||||
export interface InfoPanelProps {
|
||||
selectedCompany: { id: string; label?: string } | null;
|
||||
}
|
||||
|
||||
interface EcosystemCounts {
|
||||
knowledge_based_count: string;
|
||||
consultant_count: string;
|
||||
startup_count: string;
|
||||
innovation_center_count: string;
|
||||
accelerator_count: string;
|
||||
university_count: string;
|
||||
fund_count: string;
|
||||
company_count: string;
|
||||
actor_count: string;
|
||||
mou_count: string;
|
||||
}
|
||||
|
||||
export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCounts = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiService.callInnovationProcess<EcosystemCounts>({
|
||||
ecosystem_counts_function: {},
|
||||
});
|
||||
setCounts(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ecosystem counts:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCounts();
|
||||
}, []);
|
||||
|
||||
const title = selectedCompany?.label || "نمای کلی";
|
||||
const subTitle = selectedCompany ? `شناسه: ${selectedCompany.id}` : "انتخابی انجام نشده است";
|
||||
|
||||
// Transform counts into chart-friendly data
|
||||
const barData =
|
||||
counts && !selectedCompany
|
||||
? [
|
||||
{ name: "دانش بنیان", value: +counts.knowledge_based_count },
|
||||
{ name: "مشاور", value: +counts.consultant_count },
|
||||
{ name: "استارتاپ", value: +counts.startup_count },
|
||||
{ name: "مرکز نوآوری", value: +counts.innovation_center_count },
|
||||
{ name: "شتابدهنده", value: +counts.accelerator_count },
|
||||
{ name: "دانشگاه", value: +counts.university_count },
|
||||
{ name: "صندوق", value: +counts.fund_count },
|
||||
{ name: "شرکت", value: +counts.company_count },
|
||||
]
|
||||
: [];
|
||||
|
||||
const lineData = [
|
||||
{ month: "01", value: 3 },
|
||||
{ month: "02", value: 6 },
|
||||
{ month: "03", value: 4 },
|
||||
{ month: "04", value: 9 },
|
||||
{ month: "05", value: 7 },
|
||||
{ month: "06", value: 11 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 min-h-full">
|
||||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-persian text-base">{title}</CardTitle>
|
||||
<div className="text-xs text-gray-400 font-persian">{subTitle}</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-300 font-persian">در حال بارگذاری...</div>
|
||||
) : selectedCompany ? (
|
||||
<div className="text-sm text-gray-300 font-persian">
|
||||
این یک باکس اطلاعات نمونه است. پس از دریافت API، جزئیات شرکت نمایش داده میشود.
|
||||
</div>
|
||||
) : counts ? (
|
||||
<div className="space-y-4 text-sm text-gray-300 font-persian">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="font-bold">تعداد بازیگران اکوسیستم: </span>
|
||||
{counts.actor_count}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold">تعداد تفاهم نامه ها: </span>
|
||||
{counts.mou_count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid for categories */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>دانش بنیان: {counts.knowledge_based_count}</div>
|
||||
<div>مشاور: {counts.consultant_count}</div>
|
||||
<div>استارتاپ: {counts.startup_count}</div>
|
||||
<div>مرکز نوآوری: {counts.innovation_center_count}</div>
|
||||
<div>شتابدهنده: {counts.accelerator_count}</div>
|
||||
<div>دانشگاه: {counts.university_count}</div>
|
||||
<div>صندوق: {counts.fund_count}</div>
|
||||
<div>شرکت: {counts.company_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-300 font-persian">خطا در بارگذاری دادهها.</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Bar Chart Section */}
|
||||
<div className="h-56">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="font-persian text-sm">
|
||||
{selectedCompany ? "نمودار میلهای" : "نمودار تعداد بر اساس دستهبندی"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[180px]">
|
||||
{barData.length > 0 && (
|
||||
<CustomBarChart data={barData} dataKey="value" labelKey="name" />
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* Line/Area Chart Section */}
|
||||
<div className="h-56">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="font-persian text-sm">روند نمونه</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={lineData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#34d399"
|
||||
fill="rgba(52, 211, 153, 0.25)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InfoPanel;
|
||||
296
app/components/ecosystem/network-graph.tsx
Normal file
296
app/components/ecosystem/network-graph.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import apiService from "../../lib/api";
|
||||
import Graph from "graphology";
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
stageid: number;
|
||||
}
|
||||
|
||||
export interface NetworkGraphProps {
|
||||
onNodeClick?: (node: { id: string; label?: string; [key: string]: unknown }) => void;
|
||||
}
|
||||
|
||||
// Helper to robustly parse backend response
|
||||
function parseApiResponse(raw: any): any[] {
|
||||
let data = raw;
|
||||
try {
|
||||
if (typeof data === "string") data = JSON.parse(data);
|
||||
if (typeof data === "string") data = JSON.parse(data);
|
||||
} catch {}
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export function NetworkGraph({ onNodeClick}: NetworkGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const sigmaRef = useRef<any>(null);
|
||||
const graphRef = useRef<any>(null);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("all");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
// ------------- Fetch and robust parse ----------------
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiService.callInnovationProcess<any[]>(
|
||||
{ graph_production_function: {} }
|
||||
);
|
||||
if (aborted) return;
|
||||
// Use robust parser for backend response
|
||||
const data = parseApiResponse(JSON.parse(res.data)?.graph_production)
|
||||
setNodes(
|
||||
data.map((item: any) => ({
|
||||
id: String(item.stageid),
|
||||
label: item.title,
|
||||
category: item.category,
|
||||
stageid: item.stageid,
|
||||
}))
|
||||
);
|
||||
console.log('Fetched nodes:', data);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
// ignore
|
||||
} else {
|
||||
console.error("Failed to fetch graph data:", err);
|
||||
setNodes([]);
|
||||
}
|
||||
} finally {
|
||||
if (!aborted) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// compute unique categories
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
nodes.forEach((n) => set.add(n.category));
|
||||
return ["all", ...Array.from(set)];
|
||||
}, [nodes]);
|
||||
|
||||
// ------------- Build graph + Sigma (client-only) ----------------
|
||||
useEffect(() => {
|
||||
// don't run on server or before container available or while loading
|
||||
if (typeof window === "undefined" || !containerRef.current || isLoading) return;
|
||||
|
||||
let renderer: any = null;
|
||||
let Sigma: any = null;
|
||||
let isCancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// dynamic import for sigma only
|
||||
const sigmaModule = await import("sigma");
|
||||
Sigma = sigmaModule.default || sigmaModule.Sigma || sigmaModule;
|
||||
|
||||
if (isCancelled || !containerRef.current) return;
|
||||
|
||||
const graph = new Graph();
|
||||
graphRef.current = graph;
|
||||
|
||||
// color map (you can extend)
|
||||
const categoryToColor: Record<string, string> = {
|
||||
دانشگاه: "#3B82F6",
|
||||
مشاور: "#10B981",
|
||||
"دانش بنیان": "#F59E0B",
|
||||
استارتاپ: "#EF4444",
|
||||
شرکت: "#8B5CF6",
|
||||
صندوق: "#06B6D4",
|
||||
شتابدهنده: "#9333EA",
|
||||
"مرکز نوآوری": "#F472B6",
|
||||
center: "#000000",
|
||||
};
|
||||
|
||||
// add central node
|
||||
const CENTER_ID = "center";
|
||||
graph.addNode(CENTER_ID, {
|
||||
label: "مرکز نوآوری اصلی",
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 20,
|
||||
category: "center",
|
||||
color: categoryToColor.center,
|
||||
});
|
||||
|
||||
// add all nodes
|
||||
nodes.forEach((node, i) => {
|
||||
// Place nodes in a circle, but all nodes are always present
|
||||
const len = Math.max(1, nodes.length);
|
||||
const radius = Math.max(5, Math.min(20, Math.ceil(len / 2)));
|
||||
const angleStep = (2 * Math.PI) / len;
|
||||
const angle = i * angleStep;
|
||||
const jitter = (Math.random() - 0.5) * 0.4;
|
||||
const x = Math.cos(angle) * (radius + jitter);
|
||||
const y = Math.sin(angle) * (radius + jitter);
|
||||
graph.addNode(node.id, {
|
||||
label: node.label,
|
||||
x,
|
||||
y,
|
||||
size: 8,
|
||||
category: node.category,
|
||||
color: categoryToColor[node.category] || "#94A3B8",
|
||||
payload: node,
|
||||
});
|
||||
graph.addEdge(CENTER_ID, node.id, { size: 1, color: "#CBD5E1" });
|
||||
});
|
||||
|
||||
// Highlight nodes by filter
|
||||
const highlightByCategory = (category: string) => {
|
||||
graph.forEachNode((n: string, attrs: any) => {
|
||||
if (category === "all" || attrs.category === category) {
|
||||
graph.setNodeAttribute(n, "color", categoryToColor[attrs.category] || "#94A3B8");
|
||||
graph.setNodeAttribute(n, "size", attrs.category === "center" ? 20 : 12);
|
||||
graph.setNodeAttribute(n, "zIndex", 1);
|
||||
graph.setNodeAttribute(n, "opacity", 1);
|
||||
} else {
|
||||
graph.setNodeAttribute(n, "color", "#888888");
|
||||
graph.setNodeAttribute(n, "size", 7);
|
||||
graph.setNodeAttribute(n, "zIndex", 0);
|
||||
graph.setNodeAttribute(n, "opacity", 0.3);
|
||||
}
|
||||
});
|
||||
};
|
||||
highlightByCategory(filterCategory);
|
||||
|
||||
// Listen for filterCategory changes to re-apply highlight
|
||||
// (This is needed if filterCategory changes after initial render)
|
||||
const filterListener = () => highlightByCategory(filterCategory);
|
||||
// Optionally, you could use a custom event or observer if needed
|
||||
// For now, we rely on the effect re-running due to filterCategory in deps
|
||||
|
||||
// create renderer
|
||||
renderer = new Sigma(graph, containerRef.current, {
|
||||
renderLabels: true,
|
||||
defaultNodeColor: "#94A3B8",
|
||||
defaultEdgeColor: "#CBD5E1",
|
||||
labelColor: { color: "#fff" }, // Set label color to white
|
||||
});
|
||||
|
||||
sigmaRef.current = renderer;
|
||||
|
||||
// Helper: set highlight states by mutating node attributes
|
||||
const setHighlight = (nodeId: string | null) => {
|
||||
graph.forEachNode((n: string, attrs: any) => {
|
||||
if (nodeId && (n === nodeId || graph.hasEdge(n, nodeId) || graph.hasEdge(nodeId, n))) {
|
||||
graph.setNodeAttribute(n, "size", attrs.size ? Math.min(24, attrs.size * 1.6) : 12);
|
||||
graph.setNodeAttribute(n, "color", attrs.color ? attrs.color : "#fff");
|
||||
graph.setNodeAttribute(n, "highlighted", true);
|
||||
} else {
|
||||
graph.setNodeAttribute(n, "size", attrs.size && attrs.category === "center" ? 20 : 8);
|
||||
// restore original color if we stored it; otherwise keep
|
||||
graph.setNodeAttribute(n, "color", attrs.category === "center" ? categoryToColor.center : attrs.color);
|
||||
graph.setNodeAttribute(n, "highlighted", false);
|
||||
}
|
||||
});
|
||||
// ask renderer to refresh (sigma v2 triggers update automatically when graph changes)
|
||||
};
|
||||
|
||||
// events: hover highlight and click select
|
||||
const onEnter = (e: any) => {
|
||||
const nodeId = e.node;
|
||||
setHighlight(nodeId);
|
||||
};
|
||||
const onLeave = () => {
|
||||
setHighlight(selectedNodeId); // keep selected highlighted, or none
|
||||
};
|
||||
const onClick = (e: any) => {
|
||||
const nodeId = e.node as string;
|
||||
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
|
||||
// call external callback with payload if exists
|
||||
const attrs = graph.getNodeAttributes(nodeId);
|
||||
onNodeClick?.({ id: nodeId, label: attrs?.label, ...(attrs?.payload ?? {}) });
|
||||
};
|
||||
|
||||
renderer.on("enterNode", onEnter);
|
||||
renderer.on("leaveNode", onLeave);
|
||||
renderer.on("clickNode", onClick);
|
||||
|
||||
// if there is a pre-selected node (state), reflect it
|
||||
if (selectedNodeId) setHighlight(selectedNodeId);
|
||||
|
||||
// cleanup on re-run
|
||||
return () => {
|
||||
try {
|
||||
renderer.removeListener("enterNode", onEnter);
|
||||
renderer.removeListener("leaveNode", onLeave);
|
||||
renderer.removeListener("clickNode", onClick);
|
||||
} catch {}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize graph / sigma:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
// kill previous renderer & graph
|
||||
if (sigmaRef.current) {
|
||||
try {
|
||||
sigmaRef.current.kill?.();
|
||||
} catch {}
|
||||
}
|
||||
sigmaRef.current = null;
|
||||
graphRef.current = null;
|
||||
if (renderer) {
|
||||
try {
|
||||
renderer.kill?.();
|
||||
} catch {}
|
||||
}
|
||||
renderer = null;
|
||||
};
|
||||
// rebuild whenever nodes, filterCategory or selectedNodeId changes
|
||||
}, [nodes, filterCategory, isLoading, onNodeClick, selectedNodeId]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
<div className="p-2 flex items-center gap-2">
|
||||
<label className="text-sm">فیلتر:</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2 py-1 border rounded bg-white text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c === "all" ? "همه" : c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-4 text-sm text-gray-600">
|
||||
{isLoading ? "در حال بارگذاری..." : `نمایش ${filterCategory === "all" ? nodes.length : nodes.filter(n => n.category === filterCategory).length} گره`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 relative" style={{ minHeight: 360 }} />
|
||||
|
||||
{/* overlay selected info */}
|
||||
<div className="p-2">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<div className="text-sm">انتخاب شده: {selectedNodeId}</div>
|
||||
<button
|
||||
onClick={() => setSelectedNodeId(null)}
|
||||
className="mt-1 px-2 py-1 text-sm border rounded"
|
||||
>
|
||||
پاک کردن انتخاب
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkGraph;
|
||||
|
|
@ -269,19 +269,19 @@ export const theme = {
|
|||
|
||||
// Quick color access
|
||||
colors: {
|
||||
primary: (shade: keyof typeof themeConfig.colors.primary = '500') =>
|
||||
primary: (shade: keyof typeof themeConfig.colors.primary = 500) =>
|
||||
`var(--color-primary-${shade})`,
|
||||
secondary: (shade: keyof typeof themeConfig.colors.secondary = '500') =>
|
||||
secondary: (shade: keyof typeof themeConfig.colors.secondary = 500) =>
|
||||
`var(--color-secondary-${shade})`,
|
||||
neutral: (shade: keyof typeof themeConfig.colors.neutral = '500') =>
|
||||
neutral: (shade: keyof typeof themeConfig.colors.neutral = 500) =>
|
||||
`var(--color-neutral-${shade})`,
|
||||
success: (shade: keyof typeof themeConfig.colors.success = '500') =>
|
||||
success: (shade: keyof typeof themeConfig.colors.success = 500) =>
|
||||
`var(--color-success-${shade})`,
|
||||
error: (shade: keyof typeof themeConfig.colors.error = '500') =>
|
||||
error: (shade: keyof typeof themeConfig.colors.error = 500) =>
|
||||
`var(--color-error-${shade})`,
|
||||
warning: (shade: keyof typeof themeConfig.colors.warning = '500') =>
|
||||
warning: (shade: keyof typeof themeConfig.colors.warning = 500) =>
|
||||
`var(--color-warning-${shade})`,
|
||||
info: (shade: keyof typeof themeConfig.colors.info = '500') =>
|
||||
info: (shade: keyof typeof themeConfig.colors.info = 500) =>
|
||||
`var(--color-info-${shade})`,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export default [
|
|||
route("dashboard/project-management", "routes/project-management.tsx"),
|
||||
route("dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx"),
|
||||
route("projects", "routes/projects.tsx"),
|
||||
route("dashboard/ecosystem", "routes/ecosystem.tsx"),
|
||||
route("404", "routes/404.tsx"),
|
||||
route("unauthorized", "routes/unauthorized.tsx"),
|
||||
route("*", "routes/$.tsx"), // Catch-all route for 404s
|
||||
|
|
|
|||
69
app/routes/ecosystem.tsx
Normal file
69
app/routes/ecosystem.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { Route } from "./+types/ecosystem";
|
||||
import React from "react";
|
||||
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||
import { DashboardLayout } from "~/components/dashboard/layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
import { NetworkGraph } from "~/components/ecosystem/network-graph";
|
||||
import { InfoPanel } from "~/components/ecosystem/info-panel";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "زیست بوم فناوری" },
|
||||
{ name: "description", content: "نمایش زیست بوم فناوری با گراف شبکهای شرکتها" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function EcosystemPage() {
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<
|
||||
| { id: string; label?: string; [key: string]: unknown }
|
||||
| null
|
||||
>(null);
|
||||
const [highlightUnpaid, setHighlightUnpaid] = React.useState(false);
|
||||
|
||||
const closeDialog = () => setSelectedCompany(null);
|
||||
|
||||
return (
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<DashboardLayout title="زیست بوم فناوری">
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4">
|
||||
|
||||
<div className="lg:col-span-4">
|
||||
{//<InfoPanel selectedCompany={selectedCompany} />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8">
|
||||
<Card className="h-[70vh] lg:h-[calc(100vh-220px)]">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="font-persian text-base">گراف شبکهای شرکتها</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 h-full">
|
||||
<NetworkGraph onNodeClick={setSelectedCompany} highlightUnpaid={highlightUnpaid} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node info dialog */}
|
||||
<Dialog open={!!selectedCompany} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="font-persian">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCompany?.label || "اطلاعات شرکت"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
شناسه: {selectedCompany?.id}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>Test</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
5746
package-lock.json
generated
5746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -17,14 +17,18 @@
|
|||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@react-router/node": "^7.7.0",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"graphology": "^0.26.0",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-router": "^7.7.0",
|
||||
"recharts": "^3.1.2",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
1179
pnpm-lock.yaml
1179
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user