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
|
// Quick color access
|
||||||
colors: {
|
colors: {
|
||||||
primary: (shade: keyof typeof themeConfig.colors.primary = '500') =>
|
primary: (shade: keyof typeof themeConfig.colors.primary = 500) =>
|
||||||
`var(--color-primary-${shade})`,
|
`var(--color-primary-${shade})`,
|
||||||
secondary: (shade: keyof typeof themeConfig.colors.secondary = '500') =>
|
secondary: (shade: keyof typeof themeConfig.colors.secondary = 500) =>
|
||||||
`var(--color-secondary-${shade})`,
|
`var(--color-secondary-${shade})`,
|
||||||
neutral: (shade: keyof typeof themeConfig.colors.neutral = '500') =>
|
neutral: (shade: keyof typeof themeConfig.colors.neutral = 500) =>
|
||||||
`var(--color-neutral-${shade})`,
|
`var(--color-neutral-${shade})`,
|
||||||
success: (shade: keyof typeof themeConfig.colors.success = '500') =>
|
success: (shade: keyof typeof themeConfig.colors.success = 500) =>
|
||||||
`var(--color-success-${shade})`,
|
`var(--color-success-${shade})`,
|
||||||
error: (shade: keyof typeof themeConfig.colors.error = '500') =>
|
error: (shade: keyof typeof themeConfig.colors.error = 500) =>
|
||||||
`var(--color-error-${shade})`,
|
`var(--color-error-${shade})`,
|
||||||
warning: (shade: keyof typeof themeConfig.colors.warning = '500') =>
|
warning: (shade: keyof typeof themeConfig.colors.warning = 500) =>
|
||||||
`var(--color-warning-${shade})`,
|
`var(--color-warning-${shade})`,
|
||||||
info: (shade: keyof typeof themeConfig.colors.info = '500') =>
|
info: (shade: keyof typeof themeConfig.colors.info = 500) =>
|
||||||
`var(--color-info-${shade})`,
|
`var(--color-info-${shade})`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default [
|
||||||
route("dashboard/project-management", "routes/project-management.tsx"),
|
route("dashboard/project-management", "routes/project-management.tsx"),
|
||||||
route("dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx"),
|
route("dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx"),
|
||||||
route("projects", "routes/projects.tsx"),
|
route("projects", "routes/projects.tsx"),
|
||||||
|
route("dashboard/ecosystem", "routes/ecosystem.tsx"),
|
||||||
route("404", "routes/404.tsx"),
|
route("404", "routes/404.tsx"),
|
||||||
route("unauthorized", "routes/unauthorized.tsx"),
|
route("unauthorized", "routes/unauthorized.tsx"),
|
||||||
route("*", "routes/$.tsx"), // Catch-all route for 404s
|
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",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@react-router/node": "^7.7.0",
|
"@react-router/node": "^7.7.0",
|
||||||
"@react-router/serve": "^7.7.1",
|
"@react-router/serve": "^7.7.1",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-router": "^7.7.0",
|
"react-router": "^7.7.0",
|
||||||
|
"recharts": "^3.1.2",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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