This commit is contained in:
MehrdadAdabi 2025-09-14 16:09:20 +03:30
commit f1b4d313eb
13 changed files with 1625 additions and 34 deletions

View File

@ -441,12 +441,37 @@ export function DigitalInnovationPage() {
innovation_digital_function: {},
});
let payload: DigitalInnovationMetrics = raw?.data;
if (typeof payload === "string") {
// let payload: DigitalInnovationMetrics = raw?.data;
// console.log("*-*-*-*" +payload);
// if (typeof payload === "string") {
// try {
// payload = JSON.parse(payload).innovation_digital_function;
// } catch {}
// }
let payload: DigitalInnovationMetrics | null = null;
if (raw?.data) {
try {
payload = JSON.parse(payload);
} catch {}
// مرحله اول: data رو از string به object تبدیل کن
const parsedData = JSON.parse(raw.data);
// مرحله دوم: innovation_digital_function رو که خودش string هست parse کن
const arr = JSON.parse(parsedData.innovation_digital_function);
// مرحله سوم: اولین خانه آرایه رو بردار
if (Array.isArray(arr) && arr.length > 0) {
payload = arr[0];
}
} catch (err) {
console.error("Error parsing API response:", err);
}
}
const parseNum = (v: unknown): number => {
if (v == null) return 0;

View File

@ -1,4 +1,4 @@
import moment from "moment-jalaali";
// import moment from "moment-jalaali";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Bar,
@ -47,7 +47,7 @@ import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils";
import DashboardLayout from "../layout";
moment.loadPersian({ usePersianDigits: true });
// moment.loadPersian({ usePersianDigits: true });
interface GreenInnovationData {
WorkflowID: string;
approved_budget: string;

View File

@ -1,4 +1,3 @@
import moment from "moment-jalaali";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
@ -43,8 +42,6 @@ import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils";
import DashboardLayout from "../layout";
moment.loadPersian({ usePersianDigits: true });
interface innovationBuiltInDate {
WorkflowID: number;
approved_budget: string;
@ -194,9 +191,8 @@ export function InnovationBuiltInsidePage() {
direction: "asc",
});
const [tblAvarage, setTblAvarage] = useState<number>(0);
const [selectedProjects, setSelectedProjects] = useState<
Set<string | number>
>(new Set());
const [selectedProjects, setSelectedProjects] =
useState<Set<string | number>>();
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [selectedProjectDetails, setSelectedProjectDetails] =
useState<DialogInfo>();
@ -426,7 +422,6 @@ export function InnovationBuiltInsidePage() {
useEffect(() => {
fetchProjects(true);
fetchStats();
}, [sortConfig]);
useEffect(() => {
@ -486,7 +481,7 @@ export function InnovationBuiltInsidePage() {
const raw = await apiService.call<any>({
innovation_construction_inside_function: {
project_ids:
selectedProjects.size > 0
selectedProjects && selectedProjects?.size > 0
? Array.from(selectedProjects).join(" , ")
: "",
},
@ -622,7 +617,7 @@ export function InnovationBuiltInsidePage() {
case "select":
return (
<Checkbox
checked={selectedProjects.has(item?.project_id!)}
checked={selectedProjects?.has(item?.project_id!)}
onCheckedChange={() => handleSelectProject(item?.project_id)}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600 cursor-pointer"
/>
@ -1039,7 +1034,7 @@ export function InnovationBuiltInsidePage() {
{/* Project Details Dialog */}
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-5xl overflow-y-auto">
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-6xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
شرح پروژه
@ -1192,12 +1187,12 @@ export function InnovationBuiltInsidePage() {
</div>
) : (
<div className="tbl rounded-xl overflow-hidden">
<div className="grid grid-cols-3 bg-[#3F415A] text-white">
<span className="text-md text-center p-3">
<div className="grid grid-cols-3 bg-[#3F415A] text-white p-3 items-center">
<span className="text-md text-center">
شاخص مقایسه با نمونه خارجی
</span>
<span className="text-md text-center p-3">نمونه داخلی</span>
<span className="text-md text-center p-3">نمونه خارجی</span>
<span className="text-md text-center">نمونه داخلی</span>
<span className="text-md text-center">نمونه خارجی</span>
</div>
<div className="flex flex-col divide-y divide-gray-600 overflow-auto max-h-[6rem]">
{selectedProjectDetails?.technology_params?.map(
@ -1268,7 +1263,7 @@ export function InnovationBuiltInsidePage() {
<ResponsiveContainer width="100%" height={420}>
<LineChart
data={dialogChartData}
margin={{ top: 20, right: 20, left: 20, bottom: 80 }}
margin={{ top: 20, right: 70, left: 30, bottom: 80 }}
>
<XAxis
dataKey="name"
@ -1323,7 +1318,7 @@ export function InnovationBuiltInsidePage() {
axisOffsetX -
15;
const rectWidth = 140;
const rectWidth = 130;
const rectHeight = 28;
const rectX = x - rectWidth / 2;
const axisHeight = xAxis?.height ?? 40;

File diff suppressed because it is too large Load Diff

View File

@ -219,12 +219,7 @@ export function Sidebar({
if (item.id === "strategic-alignment") {
return (
<button
className={cn(
"w-full text-right",
)}
onClick={handleClick}
>
<div
key={item.id}
className={cn(
"flex items-center justify-center w-full px-2 rounded-lg mt-4 transition-all duration-200 group",
)}
@ -234,7 +229,6 @@ export function Sidebar({
{item.label}
</span>
</div>
</div>
</button>
)

View File

@ -0,0 +1,39 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { FunnelChart } from './funnel-chart';
const mockData = [
{ name: "تعداد کل", value: 250, label: "تعداد کل" },
{ name: "نمونه موفق", value: 130, label: "نمونه موفق" },
{ name: "محصولات موفق", value: 70, label: "محصولات موفق" },
{ name: "بهبود یا تغییر موفق", value: 80, label: "بهبود یا تغییر موفق" },
{ name: "محصول جدید", value: 50, label: "محصول جدید" },
];
describe('FunnelChart', () => {
it('renders funnel chart with correct data', () => {
render(<FunnelChart data={mockData} title="قيف فرآیند پروژه ها" />);
expect(screen.getByText('قيف فرآیند پروژه ها')).toBeInTheDocument();
expect(screen.getByText('۱۰۰%')).toBeInTheDocument();
expect(screen.getByText('۲۵%')).toBeInTheDocument();
expect(screen.getByText('ابتدا فرآیند')).toBeInTheDocument();
expect(screen.getByText('انتها فرآیند')).toBeInTheDocument();
});
it('displays funnel data values correctly', () => {
render(<FunnelChart data={mockData} />);
expect(screen.getByText('۲۵۰')).toBeInTheDocument();
expect(screen.getByText('۱۳۰')).toBeInTheDocument();
expect(screen.getByText('۷۰')).toBeInTheDocument();
expect(screen.getByText('۸۰')).toBeInTheDocument();
expect(screen.getByText('۵۰')).toBeInTheDocument();
});
it('renders without title when not provided', () => {
render(<FunnelChart data={mockData} />);
expect(screen.queryByText('قيف فرآیند پروژه ها')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,95 @@
import React from "react";
import { formatNumber } from "~/lib/utils";
interface FunnelData {
name: string;
value: number;
label: string;
percentage?: string;
}
interface FunnelChartProps {
data: FunnelData[];
title?: string;
className?: string;
}
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
const maxValue = Math.max(...data.map(d => d.value));
const toPercent = (value: number) => {
if (!maxValue || maxValue <= 0) return 0;
return Math.round((value / maxValue) * 100);
};
return (
<div className={`w-full ${className}`}>
{title && (
<h3 className="text-lg font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
{title}
</h3>
)}
<div className="flex flex-col items-center gap-2 space-y-2">
{/* Start Process Line */}
<div className="flex items-center w-full gap-10 mt-6">
<div className="text-lg text-gray-600 min-w-[max-content]">ابتدا فرآیند</div>
<div className="flex items-center w-full gap-4">
<div className="w-full h-0.5 bg-gray-600 relative">
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 top-[-1rem] -translate-y-1/2">۱۰۰%</div>
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
</div>
</div>
</div>
{/* Funnel Bars */}
<div className="flex flex-col items-center space-y-1 gap-2 w-full max-w-md">
{data.map((item, index) => {
const widthPercentage = toPercent(item.value);
const barWidth = Math.max(20, widthPercentage); // Minimum 20% width
return (
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
<div className="text-lg text-white cols-start-1 justify-self-start font-thin min-w-[max-content] text-center">
{item.label}
</div>
<div className="flex items-center gap-10 w-full cols-start-2 flex items-center justify-center w-full">
<div className="flex items-center w-full">
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
<div
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
style={{ width: `${barWidth}%` }}
>
<span className="text-[#3F415A] font-semibold">
{item.value.toLocaleString('fa-IR')}
</span>
</div>
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
</div>
</div>
</div>
);
})}
</div>
{/* End Process Line */}
<div className="flex items-center w-full gap-10">
<div className="text-lg text-gray-600 min-w-[max-content]">انتها فرآیند</div>
<div className="flex items-center w-full gap-4">
{(() => {
const lastValue = data[data.length - 1]?.value ?? 0;
const percent = toPercent(lastValue);
return (
<div style={{ width: `${percent}%` }} className={`mx-auto h-0.5 bg-gray-600 relative ${percent === 0 ? "hidden" : ""}`}>
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 bottom-[-2.5rem] -translate-y-1">{formatNumber(percent)}%</div>
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
</div>
);
})()}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import moment from "moment-jalaali";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@ -20,3 +21,23 @@ export const formatCurrency = (amount: string | number) => {
if (isNaN(numericAmount)) return "0 ریال";
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
};
export const handleDataValue = (val: any): any => {
moment.loadPersian({ usePersianDigits: true });
if (val == null) return val;
if (
typeof val === "string" &&
/^\d{4}[-/]\d{2}[-/]\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(val)
) {
return moment(val, "YYYY-MM-DD HH:mm:ss").format("YYYY/MM/DD");
}
if (
typeof val === "number" ||
(typeof val === "string" && /^-?\d+$/.test(val))
) {
return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
}
return val;
}

View File

@ -7,6 +7,10 @@ export default [
route(
"dashboard/innovation-basket/process-innovation",
"routes/innovation-basket.process-innovation.tsx"
),
route(
"dashboard/innovation-basket/product-innovation",
"routes/innovation-basket.product-innovation.tsx"
),
route(
"dashboard/innovation-basket/green-innovation",

View File

@ -0,0 +1,17 @@
import { ProductInnovationPage } from "~/components/dashboard/project-management/product-innovation-page";
import { ProtectedRoute } from "~/components/auth/protected-route";
export function meta() {
return [
{ title: "نوآوری محصول - سیستم مدیریت فناوری و نوآوری" },
{ name: "description", content: "مدیریت پروژه‌های نوآوری محصول" },
];
}
export default function ProductInnovation() {
return (
<ProtectedRoute requireAuth={true}>
<ProductInnovationPage />
</ProtectedRoute>
);
}

View File

@ -13,6 +13,7 @@
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.0.2",

View File

@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.0.2
version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-progress':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -645,6 +648,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@ -3009,6 +3025,29 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
aria-hidden: 1.2.6
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)