536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useState } from "react";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
CartesianGrid,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
|
||
import apiService from "~/lib/api";
|
||
import { formatNumber } from "~/lib/utils";
|
||
|
||
export interface CompanyDetails {
|
||
id: string;
|
||
label: string;
|
||
category: string;
|
||
stageid: number;
|
||
fields: {
|
||
F: string;
|
||
N: string;
|
||
V: string;
|
||
T: number;
|
||
U: string;
|
||
S: boolean;
|
||
}[];
|
||
description?: string;
|
||
}
|
||
|
||
export interface InfoPanelProps {
|
||
selectedCompany: CompanyDetails | 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;
|
||
}
|
||
|
||
interface ProcessActorsResponse {
|
||
start_year: string;
|
||
total_count: number;
|
||
}
|
||
|
||
interface ProcessActorsData {
|
||
year: string;
|
||
value: number;
|
||
}
|
||
|
||
export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
|
||
const [processData, setProcessData] = useState<ProcessActorsData[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
const fetchCounts = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const [countsRes, processRes] = await Promise.all([
|
||
apiService.call<EcosystemCounts>({
|
||
ecosystem_count_function: {},
|
||
}),
|
||
apiService.call<ProcessActorsResponse[]>({
|
||
process_creating_actors_function: {},
|
||
}),
|
||
]);
|
||
|
||
setCounts(
|
||
JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0],
|
||
);
|
||
|
||
// Process the years data and fill missing years
|
||
const processedData = processYearsData(
|
||
JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors),
|
||
);
|
||
setProcessData(processedData);
|
||
} catch (err) {
|
||
console.error("Failed to fetch data:", err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
fetchCounts();
|
||
}, []);
|
||
|
||
// Helper function to safely parse numbers
|
||
const parseNumber = (value: string | undefined): number => {
|
||
if (!value || value === "") return 0;
|
||
const parsed = parseInt(value, 10);
|
||
return isNaN(parsed) ? 0 : parsed;
|
||
};
|
||
|
||
// Helper function to process years data and fill missing years
|
||
const processYearsData = (
|
||
data: ProcessActorsResponse[],
|
||
): ProcessActorsData[] => {
|
||
if (!data || data.length === 0) return [];
|
||
|
||
const years = data
|
||
.map((item) => parseInt(item.start_year))
|
||
.sort((a, b) => a - b);
|
||
|
||
const minYear = years[0];
|
||
const maxYear = years[years.length - 1];
|
||
const result: ProcessActorsData[] = [];
|
||
|
||
// Create a map for quick lookup
|
||
const dataMap = data.reduce(
|
||
(acc, item) => {
|
||
acc[item.start_year] = item.total_count;
|
||
return acc;
|
||
},
|
||
{} as Record<string, number>,
|
||
);
|
||
|
||
for (let year = minYear; year <= maxYear; year++) {
|
||
result.push({
|
||
year: year.toString(),
|
||
value: dataMap[year.toString()] || 0,
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// Convert Persian years to display format without commas
|
||
const formatPersianYear = (year: string): string => {
|
||
const map: Record<string, string> = {
|
||
"0": "۰",
|
||
"1": "۱",
|
||
"2": "۲",
|
||
"3": "۳",
|
||
"4": "۴",
|
||
"5": "۵",
|
||
"6": "۶",
|
||
"7": "۷",
|
||
"8": "۸",
|
||
"9": "۹",
|
||
};
|
||
return year.replace(/[0-9]/g, (d) => map[d] ?? d);
|
||
};
|
||
|
||
// Transform counts into chart-friendly data
|
||
const barData = counts
|
||
? [
|
||
{
|
||
label: "دانش بنیان",
|
||
value: parseNumber(counts.knowledge_based_count),
|
||
},
|
||
{ label: "مشاور", value: parseNumber(counts.consultant_count) },
|
||
{ label: "استارتاپ", value: parseNumber(counts.startup_count) },
|
||
{
|
||
label: "مرکز نوآوری",
|
||
value: parseNumber(counts.innovation_center_count),
|
||
},
|
||
{ label: "شتابدهنده", value: parseNumber(counts.accelerator_count) },
|
||
{ label: "دانشگاه", value: parseNumber(counts.university_count) },
|
||
{ label: "صندوق های مالی", value: parseNumber(counts.fund_count) },
|
||
{ label: "شرکت", value: parseNumber(counts.company_count) },
|
||
]
|
||
: [];
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] min-h-full flex flex-col">
|
||
{/* Header Skeleton */}
|
||
<CardHeader className="text-center pb-2">
|
||
<div className="w-48 h-6 bg-gray-600 rounded animate-pulse mx-auto mb-4"></div>
|
||
</CardHeader>
|
||
|
||
{/* Actor Count Skeleton */}
|
||
<CardHeader className="text-center pt-0 pb-4">
|
||
<div className="w-full h-5 bg-gray-600 rounded animate-pulse mx-auto mb-2"></div>
|
||
</CardHeader>
|
||
|
||
{/* Bar Chart Skeleton */}
|
||
<CardContent className="flex-1 px-6">
|
||
<div className="w-full space-y-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center gap-3"
|
||
style={{ animationDelay: `${i * 100}ms` }}
|
||
>
|
||
{/* Label skeleton */}
|
||
<div className="w-24 h-4 bg-gray-600 rounded animate-pulse"></div>
|
||
|
||
{/* Bar skeleton */}
|
||
<div className="flex-1 bg-gray-700 rounded-full h-6">
|
||
<div
|
||
className="h-6 bg-gray-500 rounded-full animate-pulse"
|
||
style={{ width: `${Math.random() * 60 + 20}%` }}
|
||
></div>
|
||
</div>
|
||
|
||
{/* Value skeleton */}
|
||
<div className="w-8 h-4 bg-gray-600 rounded animate-pulse"></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Area Chart Skeleton */}
|
||
<CardContent className="px-6 pb-4">
|
||
<div className="mb-4">
|
||
<div className="w-40 h-5 bg-gray-600 rounded animate-pulse mb-4"></div>
|
||
</div>
|
||
<div className="h-64 bg-gray-700 rounded-lg relative overflow-hidden">
|
||
{/* Chart skeleton */}
|
||
<div className="absolute inset-4">
|
||
{/* Y-axis skeleton */}
|
||
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-600"></div>
|
||
{/* X-axis skeleton */}
|
||
<div className="absolute left-0 bottom-8 right-0 h-px bg-gray-600"></div>
|
||
|
||
{/* Area chart skeleton */}
|
||
<svg className="w-full h-full">
|
||
<defs>
|
||
<linearGradient
|
||
id="areaGradient"
|
||
x1="0%"
|
||
y1="0%"
|
||
x2="0%"
|
||
y2="100%"
|
||
>
|
||
<stop offset="0%" stopColor="#34D399" stopOpacity="0.3" />
|
||
<stop offset="100%" stopColor="#34D399" stopOpacity="0.1" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path
|
||
d="M 20 150 Q 60 100 100 120 T 180 80 T 260 90 T 340 60"
|
||
stroke="#34D399"
|
||
strokeWidth="2"
|
||
fill="none"
|
||
className="animate-pulse"
|
||
/>
|
||
<path
|
||
d="M 20 150 Q 60 100 100 120 T 180 80 T 260 90 T 340 60 L 340 180 L 20 180 Z"
|
||
fill="url(#areaGradient)"
|
||
className="animate-pulse"
|
||
/>
|
||
</svg>
|
||
|
||
{/* Data points skeleton */}
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse"
|
||
style={{
|
||
left: `${20 + i * 25}%`,
|
||
top: `${30 + Math.random() * 40}%`,
|
||
animationDelay: `${i * 200}ms`,
|
||
}}
|
||
></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Footer Skeleton */}
|
||
<CardContent className="pt-0 pb-6">
|
||
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center"></div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] min-h-full flex flex-col">
|
||
{/* Header Skeleton */}
|
||
<CardHeader className="text-center pb-2">
|
||
<div className="w-48 h-6 rounded animate-pulse mx-auto mb-4"></div>
|
||
</CardHeader>
|
||
|
||
{/* Actor Count Skeleton */}
|
||
<CardHeader className="text-center pt-0 pb-4">
|
||
<div className="w-36 h-5 rounded animate-pulse mx-auto mb-2"></div>
|
||
<div className="w-16 h-8 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
||
</CardHeader>
|
||
|
||
{/* Bar Chart Skeleton */}
|
||
<CardContent className="flex-1 px-6">
|
||
<div className="w-full space-y-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center gap-3"
|
||
style={{ animationDelay: `${i * 100}ms` }}
|
||
>
|
||
{/* Label skeleton */}
|
||
<div className="w-24 h-4 bg-gray-600 rounded animate-pulse"></div>
|
||
|
||
{/* Bar skeleton */}
|
||
<div className="flex-1 bg-gray-700 rounded-full h-6">
|
||
<div
|
||
className="h-6 bg-gray-500 rounded-full animate-pulse"
|
||
style={{ width: `${Math.random() * 60 + 20}%` }}
|
||
></div>
|
||
</div>
|
||
|
||
{/* Value skeleton */}
|
||
<div className="w-8 h-4 bg-gray-600 rounded animate-pulse"></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Area Chart Skeleton */}
|
||
<CardContent className="px-6 pb-4">
|
||
<div className="mb-4">
|
||
<div className="w-40 h-5 bg-gray-600 rounded animate-pulse mb-4"></div>
|
||
</div>
|
||
<div className="h-64 bg-gray-700 rounded-lg relative overflow-hidden">
|
||
{/* Chart skeleton */}
|
||
<div className="absolute inset-4">
|
||
{/* Y-axis skeleton */}
|
||
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-600"></div>
|
||
{/* X-axis skeleton */}
|
||
<div className="absolute left-0 bottom-8 right-0 h-px bg-gray-600"></div>
|
||
|
||
{/* Area chart skeleton */}
|
||
<svg className="w-full h-full">
|
||
<defs>
|
||
<linearGradient
|
||
id="areaGradient"
|
||
x1="0%"
|
||
y1="0%"
|
||
x2="0%"
|
||
y2="100%"
|
||
>
|
||
<stop offset="0%" stopColor="#34D399" stopOpacity="0.3" />
|
||
<stop offset="100%" stopColor="#34D399" stopOpacity="0.1" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path
|
||
d="M 20 150 Q 60 100 100 120 T 180 80 T 260 90 T 340 60"
|
||
stroke="#34D399"
|
||
strokeWidth="2"
|
||
fill="none"
|
||
className="animate-pulse"
|
||
/>
|
||
<path
|
||
d="M 20 150 Q 60 100 100 120 T 180 80 T 260 90 T 340 60 L 340 180 L 20 180 Z"
|
||
fill="url(#areaGradient)"
|
||
className="animate-pulse"
|
||
/>
|
||
</svg>
|
||
|
||
{/* Data points skeleton */}
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse"
|
||
style={{
|
||
left: `${20 + i * 25}%`,
|
||
top: `${30 + Math.random() * 40}%`,
|
||
animationDelay: `${i * 200}ms`,
|
||
}}
|
||
></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Footer Skeleton */}
|
||
<CardContent className="pt-0 pb-6">
|
||
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center">
|
||
<div className="w-28 h-4 bg-gray-600 rounded animate-pulse mx-auto mb-1"></div>
|
||
<div className="w-12 h-6 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (!counts) {
|
||
return (
|
||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] min-h-full">
|
||
<CardContent className="flex items-center justify-center h-64">
|
||
<div className="text-sm text-gray-300 font-persian">
|
||
خطا در بارگذاری دادهها.
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||
<CardHeader className="text-center pt-4 pb-3 border-b-2 border-[#3F415A]">
|
||
<CardTitle className="font-persian text-base font-semibold text-white">
|
||
وضعیت زیستبوم فناوری و نوآوری
|
||
</CardTitle>
|
||
</CardHeader>
|
||
|
||
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
|
||
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
|
||
تعداد تفاهم نامه ها
|
||
<span className="font-bold text-3xl">
|
||
{formatNumber(counts.mou_count)}
|
||
</span>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
|
||
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
|
||
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
|
||
تعداد بازیگران
|
||
<span className="font-bold text-3xl">
|
||
{formatNumber(counts.actor_count)}
|
||
</span>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
|
||
{/* Actor Count Display */}
|
||
<CardHeader className="text-right pt-4 mt-2 pb-2 text-sm font-semibold w-full">
|
||
تنوع بازیگران
|
||
</CardHeader>
|
||
{/* Middle - Bar Chart */}
|
||
<CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]">
|
||
<div className="w-full">
|
||
<CustomBarChart
|
||
hasPercent={false}
|
||
data={barData.map((item) => ({
|
||
label: item.label,
|
||
value: item.value,
|
||
valueSuffix: "",
|
||
valuePrefix: "",
|
||
maxValue: Math.max(...barData.map((d) => d.value)),
|
||
}))}
|
||
barHeight="h-5"
|
||
showAxisLabels={false}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Area Chart Section */}
|
||
<CardContent className="p-2">
|
||
<div className="px-4">
|
||
<CardTitle className="font-persian text-sm font-semibold text-white mb-2">
|
||
روند ایجاد بازیگران در طول سالها
|
||
</CardTitle>
|
||
</div>
|
||
<div className="h-42">
|
||
{processData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<AreaChart
|
||
accessibilityLayer
|
||
data={processData}
|
||
margin={{ top: 25, right: 30, left: 0, bottom: 0 }}
|
||
>
|
||
<defs>
|
||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#3AEA83" stopOpacity={1} />
|
||
<stop offset="100%" stopColor="#3AEA83" stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<CartesianGrid
|
||
vertical={false}
|
||
stroke="rgba(255,255,255,0.1)"
|
||
/>
|
||
<XAxis
|
||
dataKey="year"
|
||
stroke="#9ca3af"
|
||
fontSize={12}
|
||
tickLine={false}
|
||
tickMargin={8}
|
||
axisLine={false}
|
||
tickFormatter={formatPersianYear}
|
||
/>
|
||
<YAxis
|
||
stroke="#9ca3af"
|
||
fontSize={12}
|
||
tickMargin={12}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickFormatter={(value) => formatNumber(value)}
|
||
/>
|
||
<Tooltip cursor={false} content={<></>} />
|
||
|
||
{/* ✅ Use gradient for fill */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="value"
|
||
stroke="#3AEA83"
|
||
fill="url(#fillDesktop)"
|
||
strokeWidth={2}
|
||
activeDot={({ cx, cy, payload }) => (
|
||
<g>
|
||
{/* Small circle */}
|
||
<circle cx={cx} cy={cy} r={5} fill="#3AEA83" stroke="#fff" strokeWidth={2} />
|
||
{/* Year label above point */}
|
||
<text
|
||
x={cx}
|
||
y={cy - 10}
|
||
textAnchor="middle"
|
||
fontSize={12}
|
||
fontWeight="bold"
|
||
fill="#3AEA83"
|
||
>
|
||
{formatPersianYear(payload.year)}
|
||
</text>
|
||
</g>
|
||
)}
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
|
||
) : (
|
||
<div className="flex items-center justify-center h-full text-gray-400 font-persian">
|
||
دادهای برای نمایش وجود ندارد
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default InfoPanel;
|