Compare commits

..

60 Commits

Author SHA1 Message Date
mahmoodsht
e297522e5c آماده سازی برای دو پتروشیمی دیگر 2025-11-03 12:48:22 +03:30
mahmoodsht
69e702d368 پتروشیمی آپادانا 2025-11-02 16:50:52 +03:30
mahmoodsht
46c81cf8ea اصلاحات درخواستی 2025-11-01 19:29:09 +03:30
MehrdadAdabi
d12c3f1108 chore:clean up function 2025-10-25 19:04:33 +03:30
MehrdadAdabi
5ba67aa240 fix: change download excel 2025-10-25 19:03:19 +03:30
MehrdadAdabi
344d2a36f4 fix: change excel package 2025-10-24 22:13:49 +03:30
MehrdadAdabi
66457e9ef6 completed download excel file on project-managment page 2025-10-24 11:53:28 +03:30
MehrdadAdabi
ec461d178b change format number to fa 2025-10-21 16:23:30 +03:30
mahmoodsht
5bb9776ef0 اصلاح مکان کادرها و متن ها 2025-10-18 13:19:13 +03:30
mahmoodsht
a45ddda0f3 مخفی سازی موقت بخش های غیر فعال 2025-10-18 12:19:34 +03:30
MehrdadAdabi
ac1081cdd2 fix: bugs and chnage some logic 2025-10-17 20:05:59 +03:30
mahmoodsht
c31eba3c19 توسعه نوآوری در فرآیند 2025-10-17 17:19:54 +03:30
MehrdadAdabi
5d550217db Merge branch 'main' of http://git.sepehrdata.com/Saeed0920/inogen 2025-10-14 16:39:57 +03:30
MehrdadAdabi
b1db9e8685 fix: change date-picker logic 2025-10-14 16:39:29 +03:30
0fed828d77 update: the d3 and dashboard-custom-bar 2025-10-14 16:19:08 +03:30
mahmoodsht
7603703fa5 شماتیک پیش فرض 2025-10-14 11:13:05 +03:30
MehrdadAdabi
0fd6e4c78d fix: change next or prev btn 2025-10-13 18:44:38 +03:30
MehrdadAdabi
082856170a Merge branch 'main' of http://git.sepehrdata.com/Saeed0920/inogen 2025-10-13 18:32:35 +03:30
MehrdadAdabi
386e05d934 fix: refactor calnedar code 2025-10-13 18:29:29 +03:30
MehrdadAdabi
0dd1fe2ec2 Merge branch 'main' of http://git.sepehrdata.com/Saeed0920/inogen 2025-10-13 02:44:54 +03:30
MehrdadAdabi
efa46a02c2 fix: change date picker logic to another pages 2025-10-12 21:30:13 +03:30
8749cebe7c fix:the style in dashboard and remove the default token! 2025-10-12 14:02:07 +03:30
mahmoodsht
bda2e62411 ... 2025-10-12 12:51:51 +03:30
MehrdadAdabi
173176bbb5 feat: completed designed 2025-10-10 19:11:38 +03:30
MehrdadAdabi
584450550b Merge branch 'main' of http://git.sepehrdata.com/Saeed0920/inogen 2025-10-08 18:48:54 +03:30
MehrdadAdabi
8fc68e23a0 fix: chnage green-innovation designed 2025-10-08 18:48:16 +03:30
275e49f678 fix: the styles,and components 2025-10-08 17:23:10 +03:30
mahmoodsht
efb1990a55 جزییات 2025-10-06 10:49:00 +03:30
d451cc438a hotfix: in product-innovation 2025-10-06 06:51:09 +03:30
MehrdadAdabi
07d8fd5e8e fix:improve some styles 2025-10-05 18:20:14 +03:30
mahmoodsht
bfb3fb74c5 رفع تداخل 2025-10-05 13:10:35 +03:30
ef97d8f9b6 fix the conflicts 2025-10-05 11:40:51 +03:30
af0f1993f2 fix the button in tables 2025-10-05 11:39:37 +03:30
mahmoodsht
a14faab82d جزییات 2025-10-05 08:45:46 +03:30
MehrdadAdabi
01783dc3de fix:change styles 2025-10-04 18:59:37 +03:30
MehrdadAdabi
2ab9853b59 fix:change styles 2025-10-04 18:53:20 +03:30
MehrdadAdabi
6b611d7200 fix:change styles and update color 2025-10-04 18:45:24 +03:30
e5302e114b fix: the loading 2025-10-04 15:07:09 +03:30
97f744aadf fix the color and height 2025-10-04 15:05:04 +03:30
b0644786f1 fix the style and rerender the chart 2025-10-04 14:45:15 +03:30
a5ae3cc813 ideas (#15)
Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/15
Co-authored-by: Saeed Abadiyan <sd.eed1381@gmail.com>
Co-committed-by: Saeed Abadiyan <sd.eed1381@gmail.com>
2025-10-04 02:21:54 +03:30
mahmoodsht
b4b023ec32 جزییات گراف 2025-10-02 20:41:00 +03:30
MehrdadAdabi
9d0fd5968b fix: design bugs 2025-09-30 10:26:24 +03:30
MehrdadAdabi
cacf40938f fix: change adaption rate bg color and logic 2025-09-29 18:41:28 +03:30
MehrdadAdabi
ef96cb4778 fix: ui bugs 2025-09-28 22:41:04 +03:30
MehrdadAdabi
d4fd97daaa Merge branch 'main' of http://git.sepehrdata.com/Saeed0920/inogen 2025-09-28 22:32:45 +03:30
MehrdadAdabi
b60216c71d feat: add Compliance rate 2025-09-28 22:31:52 +03:30
mahmoodsht
921afe42fa جزییات 2025-09-28 08:11:19 +03:30
ab6084d801 fix: the padding of components 2025-09-27 16:11:46 +03:30
d67986bcba fix: fix the scroll infinite in this version 2025-09-27 15:48:53 +03:30
a6ad3b2bc2 fix the calculator of numbers 2025-09-27 15:40:19 +03:30
9205653736 fix color and componenet 2025-09-24 15:15:23 +03:30
585e66570d fix the customChartBar in dashboard and process-innvation, also fix the style in dashboard and ecosystem's popup 2025-09-23 15:41:27 +03:30
1a0cf20319 fix the font-weight and color of chart in dashboard 2025-09-22 15:37:36 +03:30
f1114e71ec fix the fonts import ,also fix the infinit scroll 2025-09-22 15:09:33 +03:30
MehrdadAdabi
f42a12c25c fix: remove percent stauts 2025-09-21 19:49:59 +03:30
97331fdf34 remove the package-lock json ,also fix the api call for project-management and fix some style in dashboard-home 2025-09-21 16:30:18 +03:30
MehrdadAdabi
85ae658c85 fix: chnage procees innovation progress bar logic 2025-09-20 21:31:50 +03:30
mahmoodsht
38534d599a جزییات 2025-09-20 13:09:14 +03:30
5887e4628f refactor_#3 (#14)
Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/14
Co-authored-by: saeed0920 <sd.eed1381@gmail.com>
Co-committed-by: saeed0920 <sd.eed1381@gmail.com>
2025-09-18 16:06:27 +03:30
44 changed files with 6595 additions and 3586 deletions

View File

@ -160,9 +160,9 @@ This document describes the exact implementation of the login page based on the
onChange={(e) => setRememberMe(e.target.checked)} onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]" className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]"
/> />
<Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer"> // <Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer">
همیشه متصل بمانم // همیشه متصل بمانم
</Label> // </Label>
</div> </div>
{/* Submit Button */} {/* Submit Button */}

View File

@ -1,11 +1,7 @@
@import "tailwindcss";
@import url(/font/fontiran.css); @import url(/font/fontiran.css);
@import "tailwindcss";
@theme { @theme {
--font-sans:
"Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* Teal color scale */ /* Teal color scale */
--color-teal-50: #f0fdfa; --color-teal-50: #f0fdfa;
--color-teal-100: #ccfbf1; --color-teal-100: #ccfbf1;
@ -32,30 +28,35 @@
--color-slate-900: #0f172a; --color-slate-900: #0f172a;
--color-slate-950: #020617; --color-slate-950: #020617;
--color-pr-green : #3AEA83; --color-pr-green: #3aea83;
--color-pr-blue : #69C8EA; --color-pr-blue: #69c8ea;
--color-pr-red : #F76276; --color-pr-red: #f76276;
--color-pr-gray : #3F415A; --color-pr-gray: #3f415a;
} }
html, html,
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
color-scheme: dark; color-scheme: dark;
} }
} }
body { body {
font-family: IRANYekanX !important; font-family: IRANYekanX;
direction: rtl; direction: rtl;
background-color: #cdcdcd; background-color: #cdcdcd;
margin: 0; margin: 0;
} }
h1, h2, h3, h4, h5, h6,input, textarea { h1,
font-family: IRANYekanX !important; h2,
h3,
h4,
h5,
h6,
input,
textarea {
font-family: IRANYekanX;
} }
/* RTL Support */ /* RTL Support */
@ -77,6 +78,7 @@ html[dir="rtl"] body {
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-dark-blue: var(--dark-blue);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
@ -96,12 +98,12 @@ html[dir="rtl"] body {
:root { :root {
--radius: 0.5rem; --radius: 0.5rem;
--color-green: #3AEA83; --color-green: #3aea83;
--color-blue: #69C8EA; --color-blue: #69c8ea;
--color-red: #F76276; --color-red: #f76276;
/* primary colors */ /* primary colors */
--color-pr-gray : #3F415A; --color-pr-gray: #3f415a;
--color-pr-green: var(--color-green); --color-pr-green: var(--color-green);
/* Light theme colors */ /* Light theme colors */
@ -124,6 +126,7 @@ html[dir="rtl"] body {
--border: #e5e5e5; --border: #e5e5e5;
--input: #e5e5e5; --input: #e5e5e5;
--ring: #22c55e; --ring: #22c55e;
--dark-blue: #33364d;
/* Primary color scale */ /* Primary color scale */
--color-primary-50: #f0fdf4; --color-primary-50: #f0fdf4;
@ -255,12 +258,11 @@ html[dir="rtl"] body {
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Persian/Farsi font class */ /* Persian/Farsi font class */
.font-persian { .font-persian {
font-family: IRANYekanX; font-family: "IRANYekanX";
} }
/* Custom utility classes */ /* Custom utility classes */
@ -420,9 +422,13 @@ html[dir="rtl"] body {
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.6), rgba(16, 185, 129, 0.9)); /* emerald */ background: linear-gradient(
to bottom,
rgba(16, 185, 129, 0.6),
rgba(16, 185, 129, 0.9)
); /* emerald */
border-radius: 9999px; border-radius: 9999px;
border: .5px solid transparent; border: 0.5px solid transparent;
background-clip: padding-box; background-clip: padding-box;
} }
@ -438,11 +444,10 @@ html[dir="rtl"] body {
.dark .custom-scrollbar::-webkit-scrollbar-thumb { .dark .custom-scrollbar::-webkit-scrollbar-thumb {
} }
:root { :root {
--form-control-color: #3F415A; --form-control-color: #3f415a;
--form-control-disabled: ##5F6284; --form-control-disabled: ##5f6284;
--form-background: #3AEA83; --form-background: #3aea83;
} }
input[type="checkbox"] { input[type="checkbox"] {
@ -450,11 +455,11 @@ input[type="checkbox"] {
appearance: none; appearance: none;
margin: 0; margin: 0;
font: inherit; font: inherit;
color: #5F6284; color: #5f6284;
background-color: transparent; background-color: transparent;
width: 1.15em; width: 1.15em;
height: 1.15em; height: 1.15em;
border: 1px solid #5F6284; border: 1px solid #5f6284;
border-radius: 0.15em; border-radius: 0.15em;
transform: translateY(-0.075em); transform: translateY(-0.075em);
display: grid; display: grid;
@ -478,7 +483,7 @@ input[type="checkbox"]:checked::before {
} }
input[type="checkbox"]:checked { input[type="checkbox"]:checked {
background-color: #3AEA83 ; background-color: #3aea83;
border: 1px solid transparent; border: 1px solid transparent;
} }

View File

@ -176,7 +176,7 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
/> />
{/* Remember Me Checkbox */} {/* Remember Me Checkbox */}
<div className="flex justify-end"> {/* <div className="flex justify-end">
<CheckboxField <CheckboxField
id="remember" id="remember"
label="همیشه متصل بمان" label="همیشه متصل بمان"
@ -185,7 +185,7 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
disabled={isLoading} disabled={isLoading}
size="md" size="md"
/> />
</div> </div> */}
{/* Login Button */} {/* Login Button */}
<Button <Button
@ -212,7 +212,9 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
{/* Right Side - Branding */} {/* Right Side - Branding */}
<LoginSidebar> <LoginSidebar>
<LoginBranding <LoginBranding
brandName="پتروشیمی بندر امام" brandName="پتروشیمی آپادانا"
// brandName="پتروشیمی نوری"
// brandName="پتروشیمی بندر امام"
engSub="Inception by Fara" engSub="Inception by Fara"
companyName="توسعه‌یافته توسط شرکت رهپویان دانش و فناوری فرا" companyName="توسعه‌یافته توسط شرکت رهپویان دانش و فناوری فرا"
logo={<img src="/brand2.svg"/>} logo={<img src="/brand2.svg"/>}

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface LoginLayoutProps { interface LoginLayoutProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@ -106,15 +107,26 @@ export function LoginBranding({
}: LoginBrandingProps) { }: LoginBrandingProps) {
return ( return (
<> <>
{/* Top Logo */}
<div className="flex justify-end"> <div className="flex justify-end">
<div className="text-slate-800 font-persian"> <div className="text-slate-800 font-persian">
<div className="text-lg font-bold leading-tight"> <div className="text-lg font-bold leading-tight">
<img src="/brand.svg" /> <img
src="/brand.svg?v=1"
alt="Brand Logo"
className="w-auto h-16" // اضافه کردن سایز مشخص
onError={(e) => {
e.target.style.display = 'none';
console.log('Image failed to load');
}}
/>
</div> </div>
</div> </div>
</div> </div>
{/* Bottom Section */} {/* Bottom Section */}
<div className="flex flex-col gap-2 mb-4 items-end justify-end"> <div className="flex flex-col gap-2 mb-4 items-end justify-end">
{logo && <div className="flex items-center">{logo}</div>} {logo && <div className="flex items-center">{logo}</div>}

View File

@ -1,3 +1,6 @@
//این فایل مخصوص
//شماتیک آپادانا
import React from "react"; import React from "react";
import { formatNumber } from "~/lib/utils"; import { formatNumber } from "~/lib/utils";
@ -8,10 +11,10 @@ export type CompanyInfo = {
costReduction: number; costReduction: number;
revenue?: number; revenue?: number;
capacity?: number; capacity?: number;
costI : number, costI: number;
capacityI : number, capacityI: number;
revenueI : number, revenueI: number;
cost : number | string, cost: number | string;
}; };
export type D3ImageInfoProps = { export type D3ImageInfoProps = {
@ -21,6 +24,8 @@ export type D3ImageInfoProps = {
}; };
const InfoBox = ({ company, style }: { company: CompanyInfo; style: any }) => { const InfoBox = ({ company, style }: { company: CompanyInfo; style: any }) => {
// const hideCapacity = company.name === "واحد 300"; // اگر واحد 300 بود ظرفیت مخفی شود
const hideCapacity = false;
return ( return (
<div className={`info-box`} style={style}> <div className={`info-box`} style={style}>
<div className="info-box-content"> <div className="info-box-content">
@ -31,32 +36,53 @@ const InfoBox = ({ company, style }: { company: CompanyInfo; style :any }) => {
</div> </div>
<div className="info-row"> <div className="info-row">
<div className="info-label">هزینه:</div> <div className="info-label">هزینه:</div>
{hideCapacity ? (
<div className="info-value cost2 text-[12px]">{formatNumber(company?.cost || 0)}</div>
) : (
<div className="info-value cost text-[12px]">{formatNumber(company?.cost || 0)}</div> <div className="info-value cost text-[12px]">{formatNumber(company?.cost || 0)}</div>
)}
<div className="info-unit">میلیون ریال</div> <div className="info-unit">میلیون ریال</div>
</div> </div>
{!hideCapacity && (
<div className="info-row"> <div className="info-row">
<div className="info-label">ظرفیت:</div> <div className="info-label">ظرفیت:</div>
<div className="info-value capacity text-[12px]">{formatNumber(company?.capacity || 0)}</div> <div className="info-value capacity text-[12px]">{formatNumber(company?.capacity || 0)}</div>
<div className="info-unit">تن در سال</div> <div className="info-unit">تن در سال</div>
</div> </div>
)}
</div> </div>
</div> </div>
); );
}; };
export function D3ImageInfo({ companies }: D3ImageInfoProps) { export function D3ImageInfo({ companies }: D3ImageInfoProps) {
// Ensure we have exactly 6 companies // واحدهای جدید - 4 واحد
const displayCompanies = companies; const sample = [
{ id: "واحد 100", name: "واحد 100", imageUrl: "/abniro.png" },
{ id: "واحد 200", name: "واحد 200", imageUrl: "/besparan.png" },
{ id: "واحد 300", name: "واحد 300", imageUrl: "/khwarazmi.png" },
{ id: "واحد 400", name: "واحد 400", imageUrl: "/faravash1.png" }
];
// Positions inside a 5x4 grid (col, row)
// Layout keeps same visual logic: left/middle/right on two bands with spacing grid around
const merged = sample.map(company => {
const found = companies.find(item => item.id === company.id);
return found
? found
: { ...company, cost: 0, capacity: 0, revenue: 0, costReduction: 0, costI: 0, capacityI: 0, revenueI: 0 };
});
const displayCompanies = merged;
console.log(displayCompanies);
// موقعیت‌های جدید برای چیدمان لوزی شکل (3 ردیف - 1-2-1)
// گرید 5x4 نگه داشته شده اما موقعیت‌ها تغییر کرده
const gridPositions = [ const gridPositions = [
{ col: 2, row: 2 , colI : 1 , rowI : 2 , name : "بسپاران"}, // left - top band { col: 2, row: 1, colI: 1, rowI: 1, name: "واحد 100" }, // ردیف اول - ستون اول
{ col: 3, row: 2 , colI : 3 , rowI : 1 , name : "خوارزمی"}, // middle top (image sits in row 2, info box goes to row 1) { col: 4, row: 1, colI: 5, rowI: 1, name: "واحد 200" }, // ردیف اول - ستون دوم
{ col: 4, row: 2 ,colI : 5 , rowI : 2 , name : "فراورش 1"}, // right - top band { col: 2, row: 3, colI: 1, rowI: 3, name: "واحد 300" }, // ردیف دوم - ستون اول
{ col: 2, row: 3 , colI : 1 , rowI : 3 , name : "کیمیا"}, // left - bottom band { col: 4, row: 3, colI: 5, rowI: 3, name: "واحد 400" }, // ردیف دوم - ستون دوم
{ col: 3, row: 3 , colI : 3, rowI : 4 , name : "آب نیرو"}, // middle bottom (image sits in row 3, info box goes to row 4)
{ col: 4, row: 3 , colI : 5 , rowI : 3 , name : "فراورش 2"}, // right - bottom band
]; ];
return ( return (
@ -65,24 +91,23 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
{displayCompanies.map((company, index) => { {displayCompanies.map((company, index) => {
const gp = gridPositions.find(v => v.name === company.name); const gp = gridPositions.find(v => v.name === company.name);
return ( return (
<> <React.Fragment key={company.id}>
<div <div
key={company.id}
className={`company-item`} className={`company-item`}
style={{ gridColumn: gp.col, gridRow: gp.row }} style={{ gridColumn: gp?.col, gridRow: gp?.row }}
> >
<div className="company-image-containe"> <div className="company-image-container">
<img <img
src={company.imageUrl} src={company.imageUrl}
alt={company.name} alt={company.name}
className="company-image" className="company-image"
/> />
</div> </div>
{company.name} {company.name}
</div> </div>
<InfoBox company={company} key={index +10} style={{ gridColumn: gp?.colI , gridRow: gp?.rowI }} /> <InfoBox company={company} style={{ gridColumn: gp?.colI, gridRow: gp?.rowI }} />
</>); </React.Fragment>
);
})} })}
</div> </div>
@ -124,10 +149,10 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
align-self: center; align-self: center;
justify-self: center; justify-self: center;
padding: .2rem 1.2rem; padding: .2rem 1.2rem;
min-width: 8rem;
background-color: transparent; background-color: transparent;
} }
.info-box-content { .info-box-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -141,10 +166,14 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
gap: .5rem; gap: .5rem;
justify-content: space-between; justify-content: space-between;
direction: rtl; direction: rtl;
}
&:has(.info-value.revenue) {border-bottom: 1px solid #3AEA83;} .info-row:has(.info-value.revenue) {
&:has(.info-value.cost) {border-bottom: 1px solid #F76276;} border-bottom: 1px solid #3AEA83;
}
.info-row:has(.info-value.cost) {
border-bottom: 1px solid #F76276;
} }
.info-label { .info-label {
@ -165,6 +194,7 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
.info-value.revenue { color: #fff; } .info-value.revenue { color: #fff; }
.info-value.cost { color: #fff; } .info-value.cost { color: #fff; }
.info-value.cost2 { color: #fff; }
.info-value.capacity { color: #fff; } .info-value.capacity { color: #fff; }
.info-unit { .info-unit {

View File

@ -0,0 +1,213 @@
//این فایل مخصوص
//شماتیک بندر امام
import React from "react";
import { formatNumber } from "~/lib/utils";
export type CompanyInfo = {
id: string;
imageUrl: string;
name: string;
costReduction: number;
revenue?: number;
capacity?: number;
costI : number,
capacityI : number,
revenueI : number,
cost : number | string,
};
export type D3ImageInfoProps = {
companies: CompanyInfo[];
width?: number;
height?: number;
};
const InfoBox = ({ company, style }: { company: CompanyInfo; style :any }) => {
const hideCapacity = company.name === "خوارزمی"; // اگر خوارزمی بود ظرفیت مخفی شود
return (
<div className={`info-box`} style={style}>
<div className="info-box-content">
<div className="info-row">
<div className="info-label">درآمد:</div>
<div className="info-value revenue text-[12px]">{formatNumber(company?.revenue || 0)}</div>
<div className="info-unit">میلیون ریال</div>
</div>
<div className="info-row">
<div className="info-label">هزینه:</div>
{
(hideCapacity ?
<div className="info-value cost2 text-[12px]">{formatNumber(company?.cost || 0)}</div>
:
<div className="info-value cost text-[12px]">{formatNumber(company?.cost || 0)}</div>
)
}
<div className="info-unit">میلیون ریال</div>
</div>
{!hideCapacity && (
<div className="info-row">
<div className="info-label">ظرفیت:</div>
<div className="info-value capacity text-[12px]">{formatNumber(company?.capacity || 0)}</div>
<div className="info-unit">تن در سال</div>
</div>
)}
</div>
</div>
);
};
export function D3ImageInfo({ companies }: D3ImageInfoProps) {
// Ensure we have exactly 6 companies
const sample = [
{ id: "آب نیرو", name: "آب نیرو", imageUrl: "/abniro.png" },
{ id: "بسپاران", name: "بسپاران", imageUrl: "/besparan.png" },
{ id: "خوارزمی", name: "خوارزمی", imageUrl: "/khwarazmi.png" },
{ id: "فراورش 1", name: "فراورش 1", imageUrl: "/faravash1.png" },
{ id: "فراورش 2", name: "فراورش 2", imageUrl: "/faravash2.png" },
{ id: "کیمیا", name: "کیمیا", imageUrl: "/kimia.png" }
];
const merged = sample.map(company => {
const found = companies.find(item => item.id == company.id);
return found
? found
: { ...company, cost: 0, capacity: 0, revenue: 0 };
});
const displayCompanies = merged;
console.log(displayCompanies)
// Positions inside a 5x4 grid (col, row)
// Layout keeps same visual logic: left/middle/right on two bands with spacing grid around
const gridPositions = [
{ col: 2, row: 2 , colI : 1 , rowI : 2 , name : "بسپاران"}, // left - top band
{ col: 3, row: 2 , colI : 3 , rowI : 1 , name : "خوارزمی"}, // middle top (image sits in row 2, info box goes to row 1)
{ col: 4, row: 2 ,colI : 5 , rowI : 2 , name : "فراورش 1"}, // right - top band
{ col: 2, row: 3 , colI : 1 , rowI : 3 , name : "کیمیا"}, // left - bottom band
{ col: 3, row: 3 , colI : 3, rowI : 4 , name : "آب نیرو"}, // middle bottom (image sits in row 3, info box goes to row 4)
{ col: 4, row: 3 , colI : 5 , rowI : 3 , name : "فراورش 2"}, // right - bottom band
];
return (
<div className="w-full h-[500px] rounded-xl">
<div dir="ltr" className="company-grid-container">
{displayCompanies.map((company, index) => {
const gp = gridPositions.find(v => v.name === company.name) ;
return (
<>
<div
key={company.id}
className={`company-item`}
style={{ gridColumn: gp.col, gridRow: gp.row }}
>
<div className="company-image-containe">
<img
src={company.imageUrl}
alt={company.name}
className="company-image"
/>
</div>
{company.name}
</div>
<InfoBox company={company} key={index +10} style={{ gridColumn: gp?.colI , gridRow: gp?.rowI }} />
</>);
})}
</div>
<style jsx>{`
.company-grid-container {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 5px;
width: 100%;
height: 500px;
}
.company-item {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.company-image-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.company-image {
object-fit: contain;
height : 100px;
}
.info-box {
border: 1px solid #3F415A;
border-radius: 10px;
height: max-content;
align-self : center;
justify-self : center;
padding : .2rem 1.2rem;
min-width : 8rem;
background-color: transparent;
}
.info-box-content {
display: flex;
flex-direction: column;
justify-content: center;
}
.info-row {
position : relative;
margin: .1rem 0;
display: flex;
gap : .5rem;
justify-content : space-between;
direction: rtl;
&:has(.info-value.revenue) {border-bottom: 1px solid #3AEA83;}
&:has(.info-value.cost) {border-bottom: 1px solid #F76276;}
}
.info-label {
color: #FFFFFF;
font-size: 11px;
font-weight: 300;
text-align: right;
margin : auto 0;
}
.info-value {
color: #34D399;
font-size: 14px;
font-weight: 500;
text-align: right;
margin-bottom : .5rem;
}
.info-value.revenue { color: #fff;}
.info-value.cost { color: #fff; }
.info-value.cost2 { color: #fff; }
.info-value.capacity { color: #fff; }
.info-unit {
position: absolute;
left: 0;
bottom: 2px;
color: #ACACAC;
font-size: 6px;
font-weight: 400;
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,211 @@
//این فایل مخصوص
//شماتیک نوری
import React from "react";
import { formatNumber } from "~/lib/utils";
export type CompanyInfo = {
id: string;
imageUrl: string;
name: string;
costReduction: number;
revenue?: number;
capacity?: number;
costI: number;
capacityI: number;
revenueI: number;
cost: number | string;
};
export type D3ImageInfoProps = {
companies: CompanyInfo[];
width?: number;
height?: number;
};
const InfoBox = ({ company, style }: { company: CompanyInfo; style: any }) => {
// const hideCapacity = company.name === "واحد 300"; // اگر واحد 300 بود ظرفیت مخفی شود
const hideCapacity = false;
return (
<div className={`info-box`} style={style}>
<div className="info-box-content">
<div className="info-row">
<div className="info-label">درآمد:</div>
<div className="info-value revenue text-[12px]">{formatNumber(company?.revenue || 0)}</div>
<div className="info-unit">میلیون ریال</div>
</div>
<div className="info-row">
<div className="info-label">هزینه:</div>
{hideCapacity ? (
<div className="info-value cost2 text-[12px]">{formatNumber(company?.cost || 0)}</div>
) : (
<div className="info-value cost text-[12px]">{formatNumber(company?.cost || 0)}</div>
)}
<div className="info-unit">میلیون ریال</div>
</div>
{!hideCapacity && (
<div className="info-row">
<div className="info-label">ظرفیت:</div>
<div className="info-value capacity text-[12px]">{formatNumber(company?.capacity || 0)}</div>
<div className="info-unit">تن در سال</div>
</div>
)}
</div>
</div>
);
};
export function D3ImageInfo({ companies }: D3ImageInfoProps) {
// واحدهای جدید - 4 واحد
const sample = [
{ id: "واحد 100", name: "واحد 100", imageUrl: "/abniro.png" },
{ id: "واحد 200", name: "واحد 200", imageUrl: "/besparan.png" },
{ id: "واحد 300", name: "واحد 300", imageUrl: "/khwarazmi.png" },
{ id: "واحد 400", name: "واحد 400", imageUrl: "/faravash1.png" }
];
const merged = sample.map(company => {
const found = companies.find(item => item.id === company.id);
return found
? found
: { ...company, cost: 0, capacity: 0, revenue: 0, costReduction: 0, costI: 0, capacityI: 0, revenueI: 0 };
});
const displayCompanies = merged;
console.log(displayCompanies);
// موقعیت‌های جدید برای چیدمان لوزی شکل (3 ردیف - 1-2-1)
// گرید 5x4 نگه داشته شده اما موقعیت‌ها تغییر کرده
const gridPositions = [
{ col: 2, row: 1, colI: 1, rowI: 1, name: "واحد 100" }, // ردیف اول - ستون اول
{ col: 4, row: 1, colI: 5, rowI: 1, name: "واحد 200" }, // ردیف اول - ستون دوم
{ col: 2, row: 3, colI: 1, rowI: 3, name: "واحد 300" }, // ردیف دوم - ستون اول
{ col: 4, row: 3, colI: 5, rowI: 3, name: "واحد 400" }, // ردیف دوم - ستون دوم
];
return (
<div className="w-full h-[500px] rounded-xl">
<div dir="ltr" className="company-grid-container">
{displayCompanies.map((company, index) => {
const gp = gridPositions.find(v => v.name === company.name);
return (
<React.Fragment key={company.id}>
<div
className={`company-item`}
style={{ gridColumn: gp?.col, gridRow: gp?.row }}
>
<div className="company-image-container">
<img
src={company.imageUrl}
alt={company.name}
className="company-image"
/>
</div>
{company.name}
</div>
<InfoBox company={company} style={{ gridColumn: gp?.colI, gridRow: gp?.rowI }} />
</React.Fragment>
);
})}
</div>
<style jsx>{`
.company-grid-container {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 5px;
width: 100%;
height: 500px;
}
.company-item {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.company-image-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.company-image {
object-fit: contain;
height: 100px;
}
.info-box {
border: 1px solid #3F415A;
border-radius: 10px;
height: max-content;
align-self: center;
justify-self: center;
padding: .2rem 1.2rem;
min-width: 8rem;
background-color: transparent;
}
.info-box-content {
display: flex;
flex-direction: column;
justify-content: center;
}
.info-row {
position: relative;
margin: .1rem 0;
display: flex;
gap: .5rem;
justify-content: space-between;
direction: rtl;
}
.info-row:has(.info-value.revenue) {
border-bottom: 1px solid #3AEA83;
}
.info-row:has(.info-value.cost) {
border-bottom: 1px solid #F76276;
}
.info-label {
color: #FFFFFF;
font-size: 11px;
font-weight: 300;
text-align: right;
margin: auto 0;
}
.info-value {
color: #34D399;
font-size: 14px;
font-weight: 500;
text-align: right;
margin-bottom: .5rem;
}
.info-value.revenue { color: #fff; }
.info-value.cost { color: #fff; }
.info-value.cost2 { color: #fff; }
.info-value.capacity { color: #fff; }
.info-unit {
position: absolute;
left: 0;
bottom: 2px;
color: #ACACAC;
font-size: 6px;
font-weight: 400;
}
`}</style>
</div>
);
}

View File

@ -1,5 +1,10 @@
import React from "react"; import React from "react";
import { formatNumber } from "~/lib/utils"; import { formatNumber } from "~/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip"
interface DataItem { interface DataItem {
label: string; label: string;
@ -54,12 +59,27 @@ export function DashboardCustomBarChart({
<div className="flex-row-reverse items-center gap-2 flex min-h-6 h-10 rounded-lg overflow-hidden"> <div className="flex-row-reverse items-center gap-2 flex min-h-6 h-10 rounded-lg overflow-hidden">
{/* Animated bar */} {/* Animated bar */}
<div <div
className={`h-auto gap-2 ${item.color} rounded-lg transition-all duration-1000 ease-out flex items-center justify-end px-2`} className={`h-auto gap-2 overflow-hidden ${item.color} rounded-lg transition-all duration-1000 ease-out flex items-center justify-end px-2`}
style={{ width: `${widthPercentage}%` }} style={{ width: `${widthPercentage}%` }}
> >
<span className="text-[#3F415A] text-left font-persian font-medium text-sm py-1 w-max"> { widthPercentage > 20 ? (
<span className="text-[#3F415A] min-w-max text-left font-persian font-medium text-sm py-1 w-max">
{item.label} {item.label}
</span> </span>
) : (
<Tooltip>
<TooltipTrigger className={`${item.color}`} asChild>
<span className="text-[#3F415A] text-left font-persian font-medium text-sm py-1">
<span className="invisible">""</span>
</span>
</TooltipTrigger>
<TooltipContent className={`${item.color} ${item.color.replace("bg","fill")}`}>
<p className="font-persian text-sm">{item.label}</p>
</TooltipContent>
</Tooltip>
) }
</div> </div>
<span className="text-white font-bold text-base"> <span className="text-white font-bold text-base">
{formatNumber(item.value)} {formatNumber(item.value)}

View File

@ -1,38 +1,6 @@
import { useState, useEffect } from "react"; import { Book, CheckCircle } from "lucide-react";
import { DashboardLayout } from "./layout"; import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Progress } from "~/components/ui/progress";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from "recharts";
import apiService from "~/lib/api";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import {
Calendar,
TrendingUp,
TrendingDown,
Target,
Lightbulb,
DollarSign,
Minus,
CheckCircle,
Book,
} from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
import { DashboardCustomBarChart } from "./dashboard-custom-bar-chart";
import { InteractiveBarChart } from "./interactive-bar-chart";
import { D3ImageInfo } from "./d3-image-info";
import { import {
Label, Label,
PolarGrid, PolarGrid,
@ -40,10 +8,21 @@ import {
RadialBar, RadialBar,
RadialBarChart, RadialBarChart,
} from "recharts"; } from "recharts";
import { ChartContainer } from "~/components/ui/chart";
import { formatNumber } from "~/lib/utils";
import { MetricCard } from "~/components/ui/metric-card";
import { BaseCard } from "~/components/ui/base-card"; import { BaseCard } from "~/components/ui/base-card";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ChartContainer } from "~/components/ui/chart";
import { MetricCard } from "~/components/ui/metric-card";
import { Progress } from "~/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api";
import { EventBus, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { D3ImageInfo } from "./d3-image-info";
import { DashboardCustomBarChart } from "./dashboard-custom-bar-chart";
import { InteractiveBarChart } from "./interactive-bar-chart";
import { DashboardLayout } from "./layout";
export function DashboardHome() { export function DashboardHome() {
const [dashboardData, setDashboardData] = useState<any | null>(null); const [dashboardData, setDashboardData] = useState<any | null>(null);
@ -51,35 +30,54 @@ export function DashboardHome() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Chart and schematic data from select API // Chart and schematic data from select API
const [companyChartData, setCompanyChartData] = useState< const [companyChartData, setCompanyChartData] = useState<
{ category: string; capacity: number; revenue: number; cost: number , costI : number, {
capacityI : number, category: string;
revenueI : number }[] capacity: number;
revenue: number;
cost: number;
costI: number;
capacityI: number;
revenueI: number;
}[]
>([]); >([]);
const [totalIncreasedCapacity, setTotalIncreasedCapacity] = useState<number>(0);
const [date, setDate] = useStoredDate();
useEffect(() => { useEffect(() => {
fetchDashboardData(); const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []); }, []);
useEffect(() => {
if (date?.end && date?.start) fetchDashboardData();
}, [date]);
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// First authenticate if needed
const token = localStorage.getItem("auth_token");
if (!token) {
await apiService.login("inogen_admin", "123456");
}
// Fetch top cards data // Fetch top cards data
const topCardsResponse = await apiService.call({ const topCardsResponse = await apiService.call({
main_page_first_function: {}, main_page_first_function: {
start_date: date.start || null,
end_date: date.end || null,
},
}); });
// Fetch left section data // Fetch left section data
const leftCardsResponse = await apiService.call({ const leftCardsResponse = await apiService.call({
main_page_second_function: {}, main_page_second_function: {
start_date: date.start || null,
end_date: date.end || null,
},
}); });
const topCardsResponseData = JSON.parse(topCardsResponse?.data); const topCardsResponseData = JSON.parse(topCardsResponse?.data);
@ -112,6 +110,10 @@ export function DashboardHome() {
"sum(pre_project_income)", "sum(pre_project_income)",
"sum(increased_income_after_innovation)", "sum(increased_income_after_innovation)",
], ],
Conditions: [
["start_date", ">=", date.start || null, "and"],
["start_date", "<=", date.end || null],
],
GroupBy: ["related_company"], GroupBy: ["related_company"],
}; };
@ -130,18 +132,36 @@ export function DashboardHome() {
let incCapacityTotal = 0; let incCapacityTotal = 0;
const chartRows = rows.map((r) => { const chartRows = rows.map((r) => {
const rel = r?.related_company ?? "-"; const rel = r?.related_company ?? "-";
const preFee = Number(r?.pre_innovation_fee_sum ?? 0) > 0 ? r?.pre_innovation_fee_sum : 0; const preFee =
const costRed = Number(r?.innovation_cost_reduction_sum ?? 0) > 0 ? r?.innovation_cost_reduction_sum : 0; Number(r?.pre_innovation_fee_sum ?? 0) >= 0
const preCap = Number(r?.pre_project_production_capacity_sum ?? 0) > 0 ? r?.pre_project_production_capacity_sum : 0; ? r?.pre_innovation_fee_sum
const incCap = Number(r?.increased_capacity_after_innovation_sum ?? 0) > 0 ? r?.increased_capacity_after_innovation_sum : 0; : 0;
const preInc = Number(r?.pre_project_income_sum ?? 0) > 0 ? r?.pre_project_income_sum : 0; const costRed =
const incInc = Number(r?.increased_income_after_innovation_sum ?? 0) > 0 ? r?.increased_income_after_innovation_sum : 0; Number(r?.innovation_cost_reduction_sum ?? 0) >= 0
? r?.innovation_cost_reduction_sum
: 0;
const preCap =
Number(r?.pre_project_production_capacity_sum ?? 0) >= 0
? r?.pre_project_production_capacity_sum
: 0;
const incCap =
Number(r?.increased_capacity_after_innovation_sum ?? 0) >= 0
? r?.increased_capacity_after_innovation_sum
: 0;
const preInc =
Number(r?.pre_project_income_sum ?? 0) >= 0
? r?.pre_project_income_sum
: 0;
const incInc =
Number(r?.increased_income_after_innovation_sum ?? 0) >= 0
? r?.increased_income_after_innovation_sum
: 0;
incCapacityTotal += incCap; incCapacityTotal += incCap;
const capacityPct = preCap > 0 ? (incCap / preCap) * 100 : 0; const capacityPct = preCap >= 0 ? (incCap / preCap) * 100 : 0;
const revenuePct = preInc > 0 ? (incInc / preInc) * 100 : 0; const revenuePct = preInc >= 0 ? (incInc / preInc) * 100 : 0;
const costPct = preFee > 0 ? (costRed / preFee) * 100 : 0; const costPct = preFee >= 0 ? (costRed / preFee) * 100 : 0;
return { return {
category: rel, category: rel,
capacity: isFinite(capacityPct) ? capacityPct : 0, capacity: isFinite(capacityPct) ? capacityPct : 0,
@ -149,12 +169,12 @@ export function DashboardHome() {
cost: isFinite(costPct) ? costPct : 0, cost: isFinite(costPct) ? costPct : 0,
costI: costRed, costI: costRed,
capacityI: incCap, capacityI: incCap,
revenueI : incInc revenueI: incInc,
}; };
}); });
setCompanyChartData(chartRows); setCompanyChartData(chartRows);
setTotalIncreasedCapacity(incCapacityTotal); // setTotalIncreasedCapacity(incCapacityTotal);
} catch (error) { } catch (error) {
console.error("Error fetching dashboard data:", error); console.error("Error fetching dashboard data:", error);
const errorMessage = const errorMessage =
@ -167,25 +187,24 @@ export function DashboardHome() {
}; };
// RadialBarChart data for ideas visualization // RadialBarChart data for ideas visualization
const getIdeasChartData = () => { // const getIdeasChartData = () => {
if (!dashboardData?.topData) // if (!dashboardData?.topData)
return [{ browser: "safari", visitors: 0, fill: "var(--color-safari)" }]; // return [{ browser: "safari", visitors: 0, fill: "var(--color-safari)" }];
const registered = parseFloat( // const registered = parseFloat(
dashboardData.topData.registered_innovation_technology_idea || "0", // dashboardData.topData.registered_innovation_technology_idea || "0"
); // );
const ongoing = parseFloat( // const ongoing = parseFloat(
dashboardData.topData.ongoing_innovation_technology_ideas || "0", // dashboardData.topData.ongoing_innovation_technology_ideas || "0"
); // );
const percentage = // const percentage = registered > 0 ? (ongoing / registered) * 100 : 0;
registered > 0 ? Math.round((ongoing / registered) * 100) : 0;
return [ // return [
{ browser: "safari", visitors: percentage, fill: "var(--color-safari)" }, // { browser: "safari", visitors: percentage, fill: "var(--color-safari)" },
]; // ];
}; // };
const chartData = getIdeasChartData(); // const chartData = getIdeasChartData();
const chartConfig = { const chartConfig = {
visitors: { visitors: {
@ -230,11 +249,11 @@ export function DashboardHome() {
style={{ height: `${Math.random() * 80 + 20}%` }} style={{ height: `${Math.random() * 80 + 20}%` }}
></div> ></div>
<div <div
className="w-full bg-green-400/30 rounded-t-sm" className="w-full bg-pr-green rounded-t-sm"
style={{ height: `${Math.random() * 80 + 20}%` }} style={{ height: `${Math.random() * 80 + 20}%` }}
></div> ></div>
<div <div
className="w-full bg-red-400/30 rounded-t-sm" className="w-full bg-pr-red rounded-t-sm"
style={{ height: `${Math.random() * 80 + 20}%` }} style={{ height: `${Math.random() * 80 + 20}%` }}
></div> ></div>
</div> </div>
@ -251,7 +270,7 @@ export function DashboardHome() {
if (loading) { if (loading) {
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="p-3 pb-0 grid grid-cols-3 gap-4 animate-pulse"> <div className="grid grid-cols-3 gap-4 animate-pulse">
{/* Top Cards Row */} {/* Top Cards Row */}
<div className="flex justify-between gap-6 [&>*]:w-full col-span-3"> <div className="flex justify-between gap-6 [&>*]:w-full col-span-3">
<SkeletonCard /> <SkeletonCard />
@ -312,7 +331,7 @@ export function DashboardHome() {
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="grid grid-cols-3 p-3 pb-0 gap-4"> <div className="grid grid-cols-3 gap-4">
{/* Top Cards Row - Redesigned to match other components */} {/* Top Cards Row - Redesigned to match other components */}
<div className="flex justify-between gap-6 [&>*]:w-full col-span-3"> <div className="flex justify-between gap-6 [&>*]:w-full col-span-3">
{/* Ideas Card */} {/* Ideas Card */}
@ -329,23 +348,22 @@ export function DashboardHome() {
visitors: visitors:
parseFloat( parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || "0", ?.registered_innovation_technology_idea || "0"
) > 0 ) > 0
? Math.round( ? Math.round(
(parseFloat( (parseFloat(
dashboardData.topData dashboardData.topData
?.ongoing_innovation_technology_ideas || ?.ongoing_innovation_technology_ideas || "0"
"0",
) / ) /
parseFloat( parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || ?.registered_innovation_technology_idea ||
"1", "1"
)) * )) *
100, 100
) )
: 0, : 0,
fill: "green", fill: "var(--color-green)",
}, },
]} ]}
startAngle={90} startAngle={90}
@ -353,19 +371,18 @@ export function DashboardHome() {
90 + 90 +
((parseFloat( ((parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || "0", ?.registered_innovation_technology_idea || "0"
) > 0 ) > 0
? Math.round( ? Math.round(
(parseFloat( (parseFloat(
dashboardData.topData dashboardData.topData
?.ongoing_innovation_technology_ideas || "0", ?.ongoing_innovation_technology_ideas || "0"
) / ) /
parseFloat( parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || ?.registered_innovation_technology_idea || "1"
"1",
)) * )) *
100, 100
) )
: 0) / : 0) /
100) * 100) *
@ -378,14 +395,10 @@ export function DashboardHome() {
gridType="circle" gridType="circle"
radialLines={false} radialLines={false}
stroke="none" stroke="none"
className="first:fill-red-400 last:fill-[#111628]" className="first:fill-pr-red last:fill-[#24273A]"
polarRadius={[38, 31]} polarRadius={[38, 31]}
/> />
<RadialBar <RadialBar dataKey="visitors" background cornerRadius={5} />
dataKey="visitors"
background
cornerRadius={5}
/>
<PolarRadiusAxis <PolarRadiusAxis
tick={false} tick={false}
tickLine={false} tickLine={false}
@ -411,22 +424,22 @@ export function DashboardHome() {
parseFloat( parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || ?.registered_innovation_technology_idea ||
"0", "0"
) > 0 ) > 0
? Math.round( ? Math.round(
(parseFloat( (parseFloat(
dashboardData.topData dashboardData.topData
?.ongoing_innovation_technology_ideas || ?.ongoing_innovation_technology_ideas ||
"0", "0"
) / ) /
parseFloat( parseFloat(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || ?.registered_innovation_technology_idea ||
"1", "1"
)) * )) *
100, 100
) )
: 0, : 0
)} )}
</tspan> </tspan>
</text> </text>
@ -443,14 +456,14 @@ export function DashboardHome() {
<div className="font-light text-sm">ثبت شده :</div> <div className="font-light text-sm">ثبت شده :</div>
{formatNumber( {formatNumber(
dashboardData.topData dashboardData.topData
?.registered_innovation_technology_idea || "0", ?.registered_innovation_technology_idea || "0"
)} )}
</span> </span>
<span className="flex items-center gap-1 font-bold text-base"> <span className="flex items-center gap-1 font-bold text-base">
<div className="font-light text-sm">در حال اجرا :</div> <div className="font-light text-sm">در حال اجرا :</div>
{formatNumber( {formatNumber(
dashboardData.topData dashboardData.topData
?.ongoing_innovation_technology_ideas || "0", ?.ongoing_innovation_technology_ideas || "0"
)} )}
</span> </span>
</div> </div>
@ -460,16 +473,34 @@ export function DashboardHome() {
{/* Revenue Card */} {/* Revenue Card */}
<MetricCard <MetricCard
title="افزایش درآمد مبتنی بر فناوری و نوآوری" title="افزایش درآمد مبتنی بر فناوری و نوآوری"
value={dashboardData.topData?.technology_innovation_based_revenue_growth || "0"} value={
percentValue={Math.round(dashboardData.topData?.technology_innovation_based_revenue_growth_percent) || "0"} dashboardData.topData?.technology_innovation_based_revenue_growth?.replaceAll(
",",
""
) || "0"
}
percentValue={
dashboardData.topData
?.technology_innovation_based_revenue_growth_percent
}
percentLabel="درصد به کل درآمد" percentLabel="درصد به کل درآمد"
/> />
{/* Cost Reduction Card */} {/* Cost Reduction Card */}
<MetricCard <MetricCard
title="کاهش هزینه ها مبتنی بر فناوری و نوآوری" title="کاهش هزینه ها مبتنی بر فناوری و نوآوری"
value={Math.round(parseFloat(dashboardData.topData?.technology_innovation_based_cost_reduction?.replace(/,/g, "") || "0") / 1000000)} value={Math.round(
percentValue={Math.round(dashboardData.topData?.technology_innovation_based_cost_reduction_percent) || "0"} parseFloat(
dashboardData.topData?.technology_innovation_based_cost_reduction?.replace(
/,/g,
""
) || "0"
)
)}
percentValue={
dashboardData.topData
?.technology_innovation_based_cost_reduction_percent || "0"
}
percentLabel="درصد به کل هزینه" percentLabel="درصد به کل هزینه"
/> />
@ -486,9 +517,9 @@ export function DashboardHome() {
browser: "budget", browser: "budget",
visitors: parseFloat( visitors: parseFloat(
dashboardData.topData dashboardData.topData
?.innovation_budget_achievement_percent || "0", ?.innovation_budget_achievement_percent || "0"
), ),
fill: "green", fill: "var(--color-green)",
}, },
]} ]}
startAngle={90} startAngle={90}
@ -506,14 +537,10 @@ export function DashboardHome() {
gridType="circle" gridType="circle"
radialLines={false} radialLines={false}
stroke="none" stroke="none"
className="first:fill-red-400 last:fill-[#111628]" className="first:fill-pr-red last:fill-[#24273A]"
polarRadius={[38, 31]} polarRadius={[38, 31]}
/> />
<RadialBar <RadialBar dataKey="visitors" background cornerRadius={5} />
dataKey="visitors"
background
cornerRadius={5}
/>
<PolarRadiusAxis <PolarRadiusAxis
tick={false} tick={false}
tickLine={false} tickLine={false}
@ -539,8 +566,8 @@ export function DashboardHome() {
Math.round( Math.round(
dashboardData.topData dashboardData.topData
?.innovation_budget_achievement_percent || ?.innovation_budget_achievement_percent ||
0, 0
), )
)} )}
</tspan> </tspan>
</text> </text>
@ -560,10 +587,10 @@ export function DashboardHome() {
parseFloat( parseFloat(
dashboardData.topData?.approved_innovation_budget_achievement_ratio?.replace( dashboardData.topData?.approved_innovation_budget_achievement_ratio?.replace(
/,/g, /,/g,
"", ""
) || "0", ) || "0"
) / 1000000000, )
), )
)} )}
</span> </span>
<span className="flex items-center gap-1 text-base font-bold mr-auto"> <span className="flex items-center gap-1 text-base font-bold mr-auto">
@ -573,10 +600,10 @@ export function DashboardHome() {
parseFloat( parseFloat(
dashboardData.topData?.allocated_innovation_budget_achievement_ratio?.replace( dashboardData.topData?.allocated_innovation_budget_achievement_ratio?.replace(
/,/g, /,/g,
"", ""
) || "0", ) || "0"
) / 1000000000, )
), )
)} )}
</span> </span>
</div> </div>
@ -587,18 +614,21 @@ export function DashboardHome() {
{/* Main Content with Tabs */} {/* Main Content with Tabs */}
<Tabs <Tabs
defaultValue="charts" defaultValue="canvas"
className="grid overflow-hidden rounded-lg grid-rows-[max-content] items-center col-span-2 row-start-2 bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]" className="grid overflow-hidden rounded-lg grid-rows-[max-content] items-center col-span-2 row-start-2 bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]"
> >
<div className="flex items-center border-b border-gray-600 justify-between gap-2"> <div className="flex items-center border-b border-gray-600 justify-between gap-2">
<p className="p-6 font-persian font-semibold text-lg "> <p className="p-6 font-persian font-semibold text-lg ">
تحقق ارزش ها تحقق ارزش ها
</p> </p>
<TabsList className="bg-transparent py-2 border m-6 border-gray-600"> <TabsList className="bg-transparent py-2 m-6 border-[1px] border-[#5F6284]">
<TabsTrigger value="canvas" className=""> <TabsTrigger value="canvas" className="cursor-pointer">
شماتیک شماتیک
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="charts" className=" text-white font-light "> <TabsTrigger
value="charts"
className=" text-white cursor-pointer font-light "
>
مقایسه ای مقایسه ای
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -611,17 +641,39 @@ export function DashboardHome() {
<TabsContent value="canvas" className="w-ful h-full"> <TabsContent value="canvas" className="w-ful h-full">
<div className="p-4 h-full w-full"> <div className="p-4 h-full w-full">
<D3ImageInfo <D3ImageInfo
companies={
companyChartData.map((item) => { //پتروشیمی بندر امام
// companies={companyChartData.map((item) => {
// const imageMap: Record<string, string> = {
// بسپاران: "/besparan.png",
// خوارزمی: "/khwarazmi.png",
// "فراورش 1": "/faravash1.png",
// "فراورش 2": "/faravash2.png",
// کیمیا: "/kimia.png",
// "آب نیرو": "/abniro.png",
// };
//پتروشیمی آپادانا
companies={companyChartData.map((item) => {
const imageMap: Record<string, string> = { const imageMap: Record<string, string> = {
"بسپاران": "/besparan.png", "واحد 100": "/abniro.png" ,
"خوارزمی": "/khwarazmi.png", "واحد 200": "/besparan.png" ,
"فراورش 1": "/faravash1.png", "واحد 300": "/khwarazmi.png" ,
"فراورش 2": "/faravash2.png", "واحد 400": "/faravash1.png"
"کیمیا": "/kimia.png",
"آب نیرو": "/abniro.png",
}; };
//پتروشیمی نوری
// companies={companyChartData.map((item) => {
// const imageMap: Record<string, string> = {
// "واحد 100": "/abniro.png" ,
// "واحد 200": "/besparan.png" ,
// "واحد 300": "/khwarazmi.png" ,
// "واحد 400": "/faravash1.png"
// };
return { return {
id: item.category, id: item.category,
name: item.category, name: item.category,
@ -630,8 +682,7 @@ export function DashboardHome() {
capacity: item?.capacityI || 0, capacity: item?.capacityI || 0,
revenue: item?.revenueI || 0, revenue: item?.revenueI || 0,
}; };
}) })}
}
/> />
</div> </div>
</TabsContent> </TabsContent>
@ -646,17 +697,10 @@ export function DashboardHome() {
<CardTitle className="text-white text-sm min-w-[100px]"> <CardTitle className="text-white text-sm min-w-[100px]">
شدت فناوری شدت فناوری
</CardTitle> </CardTitle>
<p className="text-base text-left">
%
{formatNumber(
Math.round(
dashboardData.leftData?.technology_intensity || 0,
),
)}
</p>
<Progress <Progress
value={parseFloat( value={parseFloat(
dashboardData.leftData?.technology_intensity || "0", dashboardData.leftData?.technology_intensity
)} )}
className="h-4 flex-1" className="h-4 flex-1"
/> />
@ -674,21 +718,21 @@ export function DashboardHome() {
{ {
label: "اجرا شده", label: "اجرا شده",
value: parseFloat( value: parseFloat(
dashboardData?.leftData?.executed_project || "0", dashboardData?.leftData?.executed_project || "0"
), ),
color: "bg-pr-green", color: "bg-pr-green",
}, },
{ {
label: "در حال اجرا", label: "در حال اجرا",
value: parseFloat( value: parseFloat(
dashboardData?.leftData?.in_progress_project || "0", dashboardData?.leftData?.in_progress_project || "0"
), ),
color: "bg-pr-blue", color: "bg-pr-blue",
}, },
{ {
label: "برنامه‌ریزی شده", label: "برنامه‌ریزی شده",
value: parseFloat( value: parseFloat(
dashboardData?.leftData?.planned_project || "0", dashboardData?.leftData?.planned_project || "0"
), ),
color: "bg-pr-red", color: "bg-pr-red",
}, },
@ -713,7 +757,7 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.printed_books_count || "0", dashboardData.leftData?.printed_books_count || "0"
)} )}
</span> </span>
</div> </div>
@ -724,7 +768,7 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.registered_patents_count || "0", dashboardData.leftData?.registered_patents_count || "0"
)} )}
</span> </span>
</div> </div>
@ -735,18 +779,18 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.published_reports_count || "0", dashboardData.leftData?.published_reports_count || "0"
)} )}
</span> </span>
</div> </div>
<div className="flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Book className="w-4 h-4 text-green-400" /> <Book className="w-4 h-4 text-pr-green" />
<span className="text-sm">مقاله:</span> <span className="text-sm">مقاله:</span>
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.printed_articles_count || "0", dashboardData.leftData?.printed_articles_count || "0"
)} )}
</span> </span>
</div> </div>
@ -770,7 +814,7 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.attended_conferences_count || "0", dashboardData.leftData?.attended_conferences_count || "0"
)} )}
</span> </span>
</div> </div>
@ -781,7 +825,7 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.attended_events_count || "0", dashboardData.leftData?.attended_events_count || "0"
)} )}
</span> </span>
</div> </div>
@ -792,18 +836,18 @@ export function DashboardHome() {
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.attended_exhibitions_count || "0", dashboardData.leftData?.attended_exhibitions_count || "0"
)} )}
</span> </span>
</div> </div>
<div className="flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Book className="w-4 h-4 text-green-400" /> <Book className="w-4 h-4 text-pr-green" />
<span className="text-sm">برگزاری رویداد:</span> <span className="text-sm">برگزاری رویداد:</span>
</div> </div>
<span className="text-base font-bold "> <span className="text-base font-bold ">
{formatNumber( {formatNumber(
dashboardData.leftData?.organized_events_count || "0", dashboardData.leftData?.organized_events_count || "0"
)} )}
</span> </span>
</div> </div>
@ -813,7 +857,6 @@ export function DashboardHome() {
</div> </div>
</div> </div>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@ -1,21 +1,22 @@
import React, { useEffect, useState } from "react"; import { saveAs } from "file-saver";
import { useAuth } from "~/contexts/auth-context"; import jalaali from "jalaali-js";
import { Link } from "react-router";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { import {
PanelLeft, Calendar,
Settings,
User,
Menu,
ChevronDown,
Server,
ChevronLeft, ChevronLeft,
FileChartColumnIncreasing,
Menu,
PanelLeft,
Server,
User,
} from "lucide-react"; } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router";
import XLSX from "xlsx-js-style";
import { Button } from "~/components/ui/button";
import { Calendar as CustomCalendar } from "~/components/ui/Calendar";
import { useAuth } from "~/contexts/auth-context";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { cn, EventBus, handleDataValue } from "~/lib/utils";
interface HeaderProps { interface HeaderProps {
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
@ -24,6 +25,161 @@ interface HeaderProps {
titleIcon?: React.ComponentType<{ className?: string }> | null; titleIcon?: React.ComponentType<{ className?: string }> | null;
} }
interface MonthItem {
id: string;
label: string;
start: string;
end: string;
}
interface CurrentDay {
start?: string;
end?: string;
sinceMonth?: string;
fromMonth?: string;
}
interface SelectedDate {
since?: number;
until?: number;
}
const monthList: Array<MonthItem> = [
{
id: "month-1",
label: "بهار",
start: "01/01",
end: "03/31",
},
{
id: "month-2",
label: "تابستان",
start: "04/01",
end: "06/31",
},
{
id: "month-3",
label: "پاییز",
start: "07/01",
end: "09/31",
},
{
id: "month-4",
label: "زمستان",
start: "10/01",
end: "12/30",
},
];
const columns: Array<any> = [
{ key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
{
key: "importance_project",
label: "میزان اهمیت",
sortable: true,
width: "160px",
},
{
key: "strategic_theme",
label: "مضمون راهبردی",
sortable: true,
width: "200px",
},
{
key: "value_technology_and_innovation",
label: "ارزش فناوری و نوآوری",
sortable: true,
width: "220px",
},
{
key: "type_of_innovation",
label: "انواع نوآوری",
sortable: true,
width: "160px",
},
{
key: "innovation",
label: "میزان نوآوری",
sortable: true,
width: "140px",
},
{
key: "person_executing",
label: "مسئول اجرا",
sortable: true,
width: "180px",
},
{
key: "excellent_observer",
label: "ناطر عالی",
sortable: true,
width: "180px",
},
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "180px" },
{ key: "moderator", label: "مجری", sortable: true, width: "180px" },
{
key: "executive_phase",
label: "فاز اجرایی",
sortable: true,
width: "160px",
},
{
key: "start_date",
label: "تاریخ شروع",
sortable: true,
width: "120px",
},
{
key: "remaining_time",
label: "زمان باقی مانده",
sortable: true,
width: "140px",
computed: true,
},
{
key: "end_date",
label: "تاریخ پایان (برنامه‌ریزی)",
sortable: true,
width: "160px",
},
{
key: "renewed_duration",
label: "مدت زمان تمدید",
sortable: true,
width: "140px",
},
{
key: "done_date",
label: "تاریخ پایان (واقعی)",
sortable: true,
width: "160px",
},
{
key: "deviation_from_program",
label: "متوسط انحراف برنامه‌ای",
sortable: true,
width: "160px",
},
{
key: "approved_budget",
label: "بودجه مصوب",
sortable: true,
width: "150px",
},
{
key: "budget_spent",
label: "بودجه صرف شده",
sortable: true,
width: "150px",
},
{
key: "cost_deviation",
label: "متوسط انحراف هزینه‌ای",
sortable: true,
width: "160px",
},
];
export function Header({ export function Header({
onToggleSidebar, onToggleSidebar,
className, className,
@ -31,25 +187,217 @@ export function Header({
titleIcon, titleIcon,
}: HeaderProps) { }: HeaderProps) {
const { user } = useAuth(); const { user } = useAuth();
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const { jy } = jalaali.toJalaali(new Date());
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
const calendarRef = useRef<HTMLDivElement>(null);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState<boolean>(false);
const [isNotificationOpen, setIsNotificationOpen] = useState<boolean>(false);
const [openCalendar, setOpenCalendar] = useState<boolean>(false);
const [excelLoading, setExcelLoading] = useState<boolean>(false);
const location = useLocation();
const projectManagerRoute = "/dashboard/project-management";
const [currentYear, setCurrentYear] = useState<SelectedDate>({
since: jy,
until: jy,
});
const [selectedDate, setSelectedDate] = useState<CurrentDay>({});
useEffect(() => {
const storedDate = localStorage.getItem("dateSelected");
if (storedDate) {
const parsedDate = JSON.parse(storedDate);
setSelectedDate(parsedDate);
const sinceYear = parsedDate.start
? parseInt(parsedDate.start.split("/")[0], 10)
: jy;
const untilYear = parsedDate.end
? parseInt(parsedDate.end.split("/")[0], 10)
: jy;
setCurrentYear({ since: sinceYear, until: untilYear });
} else {
const defaultDate = {
sinceMonth: "بهار",
fromMonth: "زمستان",
start: `${jy}/01/01`,
end: `${jy}/12/30`,
};
setSelectedDate(defaultDate);
localStorage.setItem("dateSelected", JSON.stringify(defaultDate));
setCurrentYear({ since: jy, until: jy });
}
}, []);
const redirectHandler = async () => { const redirectHandler = async () => {
try { try {
const getData = await apiService.post('/GenerateSsoCode') const getData = await apiService.post("/GenerateSsoCode");
const url = `http://localhost:3000/redirect/${getData.data}`;
//بندر امام
// const url = `https://inogen-bpms.pelekan.org/redirect/${getData.data}`; // const url = `https://inogen-bpms.pelekan.org/redirect/${getData.data}`;
//آپادانا
const url = `https://APADANA-IATM-bpms.pelekan.org/redirect/${getData.data}`;
//نوری
// const url = `https://NOPC-IATM-bpms.pelekan.org/redirect/${getData.data}`;
window.open(url, "_blank"); window.open(url, "_blank");
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
};
const changeSinceYear = (delta: number) => {
if (!currentYear) return;
const newSince = (currentYear.since ?? 0) + delta;
if (newSince > (currentYear.until ?? Infinity) || newSince < 0) return;
const updatedYear = { ...currentYear, since: newSince };
setCurrentYear(updatedYear);
const updatedDate = {
...selectedDate,
start: `${newSince}/${selectedDate.start?.split("/").slice(1).join("/")}`,
};
setSelectedDate(updatedDate);
localStorage.setItem("dateSelected", JSON.stringify(updatedDate));
EventBus.emit("dateSelected", updatedDate);
};
const nextFromYearHandler = () => changeSinceYear(1);
const prevFromYearHandler = () => changeSinceYear(-1);
const selectFromDateHandler = (val: MonthItem) => {
const data = {
...selectedDate,
start: `${currentYear.since}/${val.start}`,
sinceMonth: val.label,
};
setSelectedDate(data);
localStorage.setItem("dateSelected", JSON.stringify(data));
EventBus.emit("dateSelected", data);
};
const changeUntilYear = (delta: number) => {
if (!currentYear) return;
const newUntil = (currentYear.until ?? 0) + delta;
if (newUntil < (currentYear.since ?? 0)) return;
const updatedYear = { ...currentYear, until: newUntil };
setCurrentYear(updatedYear);
const updatedDate = {
...selectedDate,
end: `${newUntil}/${selectedDate.end?.split("/").slice(1).join("/")}`,
};
setSelectedDate(updatedDate);
localStorage.setItem("dateSelected", JSON.stringify(updatedDate));
EventBus.emit("dateSelected", updatedDate);
};
const nextUntilYearHandler = () => changeUntilYear(1);
const prevUntilYearHandler = () => changeUntilYear(-1);
const selectUntilDateHandler = (val: MonthItem) => {
const data = {
...selectedDate,
end: `${currentYear.until}/${val.end}`,
fromMonth: val.label,
};
setSelectedDate(data);
localStorage.setItem("dateSelected", JSON.stringify(data));
EventBus.emit("dateSelected", data);
toggleCalendar();
};
const toggleCalendar = () => {
setOpenCalendar(!openCalendar);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
calendarRef.current &&
!calendarRef.current.contains(event.target as Node)
) {
setOpenCalendar(false);
} }
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const exportToExcel = async () => {
let arr = [];
const data: any = await fetchExcelData();
for (let i = 0; i < data.length; i++) {
let obj: Record<string, any> = {};
const project = data[i];
Object.entries(project).forEach(([pKey, pValue]: [any, any]) => {
Object.values(columns).forEach((col) => {
if (pKey === col?.key) {
``;
obj[col?.label] = handleDataValue(
pValue?.includes(",") ? pValue.replaceAll(",", "") : pValue
);
}
});
});
arr.push(obj);
}
const worksheet = XLSX.utils.json_to_sheet(arr);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "People");
const excelBuffer = XLSX.write(workbook, {
bookType: "xlsx",
type: "array",
});
const blob = new Blob([excelBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
saveAs(blob, "reports.xls");
};
const fetchExcelData = async () => {
setExcelLoading(true);
const fetchableColumns = columns.filter((c) => !c.computed);
const outputFields = fetchableColumns.map((c) => c.apiField ?? c.key);
const response = await apiService.select({
ProcessName: "project",
OutputFields: outputFields,
Conditions: [
["start_date", ">=", selectedDate?.start || null, "and"],
["start_date", "<=", selectedDate?.end || null],
],
});
const parsedData = JSON.parse(response.data);
setExcelLoading(false);
return parsedData;
};
const handleDownloadFile = () => {
if (excelLoading) return null;
else exportToExcel();
};
return ( return (
<header <header
className={cn( className={cn(
"backdrop-blur-sm border-b border-gray-400/30 h-16 flex items-center justify-between px-4 lg:px-6 shadow-sm relative z-30", "backdrop-blur-sm border-b border-gray-400/30 h-16 flex items-center justify-between px-4 lg:px-6 shadow-sm relative z-30",
className, className
)} )}
> >
{/* Left Section */} {/* Left Section */}
@ -77,16 +425,70 @@ export function Header({
<PanelLeft /> <PanelLeft />
)} )}
{title.includes("-") ? ( {title.includes("-") ? (
<span className="flex items-center gap-1"> <div className="flex row items-center gap-4">
<div className="flex items-center gap-1">
{title.split("-")[0]} {title.split("-")[0]}
<ChevronLeft className="inline-block w-4 h-4" /> <ChevronLeft className="inline-block w-4 h-4" />
{title.split("-")[1]} {title.split("-")[1]}
</span> </div>
</div>
) : ( ) : (
title title
)} )}
</h1> </h1>
<div ref={calendarRef} className="flex flex-col gap-3 relative">
<div
onClick={toggleCalendar}
className="flex flex-row w-full gap-2 items-center border border-pr-gray p-1.5 rounded-md px-2.5 min-w-64 cursor-pointer hover:bg-pr-gray/50 transition-all duration-300"
>
<Calendar size={20} />
{selectedDate ? (
<div className="flex flex-row justify-between w-full min-w-36 font-bold gap-1">
<div className="flex flex-row gap-1.5 w-max">
<span className="text-md">از</span>
<span className="text-md">{selectedDate?.sinceMonth}</span>
<span className="text-md">
{handleDataValue(currentYear.since)}
</span>
</div>
<div className="flex flex-row gap-1.5 w-max">
<span className="text-md">تا</span>
<span className="text-md">{selectedDate?.fromMonth}</span>
<span className="text-md">
{handleDataValue(currentYear.until)}
</span>
</div>
</div>
) : (
"تاریخ مورد نظر خود را انتخاب نمایید"
)}
</div>
{openCalendar && (
<div className="flex flex-row gap-2.5 absolute top-14 right-[-40px] p-2.5 !pt-3.5 w-80 rounded-3xl overflow-hidden bg-pr-gray border-2 border-[#5F6284]">
<CustomCalendar
title="از"
nextYearHandler={prevFromYearHandler}
prevYearHandler={nextFromYearHandler}
currentYear={handleDataValue(currentYear?.since)}
monthList={monthList}
selectedDate={selectedDate?.sinceMonth}
selectDateHandler={selectFromDateHandler}
/>
<span className="w-0.5 h-[12.5rem] border border-[#5F6284] block "></span>
<CustomCalendar
title="تا"
nextYearHandler={prevUntilYearHandler}
prevYearHandler={nextUntilYearHandler}
currentYear={handleDataValue(currentYear?.until)}
monthList={monthList}
selectedDate={selectedDate?.fromMonth}
selectDateHandler={selectUntilDateHandler}
/>
</div>
)}
</div>
</div> </div>
{/* Right Section */} {/* Right Section */}
@ -94,14 +496,29 @@ export function Header({
{/* User Menu */} {/* User Menu */}
<div className="relative"> <div className="relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{location.pathname === projectManagerRoute ? (
<div className="flex justify-end w-full mb-0 pl-2">
<span
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian ${excelLoading ? "!cursor-not-allowed !opacity-10" : ""}`}
onClick={handleDownloadFile}
>
<FileChartColumnIncreasing className="h-4 w-4" />
دانلود فایل اکسل
</span>
</div>
) : (
""
)}
{ {user?.id === 2041 && (
user?.id === 2041 && <button <button
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian" className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian"
onClick={redirectHandler}> onClick={redirectHandler}
>
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
ورود به میزکار مدیریت</button> ورود به میزکار مدیریت
} </button>
)}
<Button <Button
variant="ghost" variant="ghost"
@ -109,7 +526,6 @@ export function Header({
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)} onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
className="flex items-center gap-2 text-gray-300" className="flex items-center gap-2 text-gray-300"
> >
<div className="hidden sm:block text-right"> <div className="hidden sm:block text-right">
<div className="text-sm font-medium font-persian"> <div className="text-sm font-medium font-persian">
{user?.name} {user?.family} {user?.name} {user?.family}
@ -123,6 +539,7 @@ export function Header({
</div> </div>
</Button> </Button>
</div> </div>
{/* Profile Dropdown */} {/* Profile Dropdown */}
{isProfileMenuOpen && ( {isProfileMenuOpen && (
<div className="absolute left-0 top-full mt-2 w-48 bg-gray-800 border border-emerald-500/30 rounded-lg shadow-lg z-50"> <div className="absolute left-0 top-full mt-2 w-48 bg-gray-800 border border-emerald-500/30 rounded-lg shadow-lg z-50">
@ -134,7 +551,7 @@ export function Header({
{user?.email} {user?.email}
</div> </div>
</div> </div>
<div className="py-1"> {/* <div className="py-1">
<Link <Link
to="/dashboard/profile" to="/dashboard/profile"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian" className="flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian"
@ -151,7 +568,7 @@ export function Header({
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
تنظیمات تنظیمات
</Link> </Link>
</div> </div> */}
</div> </div>
)} )}
</div> </div>

View File

@ -1,9 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Sidebar } from "./sidebar";
import { Header } from "./header"; import { Header } from "./header";
import { Sidebar } from "./sidebar";
import { StrategicAlignmentPopup } from "./strategic-alignment-popup"; import { StrategicAlignmentPopup } from "./strategic-alignment-popup";
import apiService from "~/lib/api";
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -18,9 +17,14 @@ export function DashboardLayout({
}: DashboardLayoutProps) { }: DashboardLayoutProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] = useState(false); const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] =
const [currentTitle, setCurrentTitle] = useState<string | undefined>(title ?? "صفحه اول"); useState(false);
const [currentTitleIcon, setCurrentTitleIcon] = useState<React.ComponentType<{ className?: string }> | null | undefined>(undefined); const [currentTitle, setCurrentTitle] = useState<string | undefined>(
title ?? "صفحه اول"
);
const [currentTitleIcon, setCurrentTitleIcon] = useState<
React.ComponentType<{ className?: string }> | null | undefined
>(undefined);
const toggleSidebarCollapse = () => { const toggleSidebarCollapse = () => {
setIsSidebarCollapsed(!isSidebarCollapsed); setIsSidebarCollapsed(!isSidebarCollapsed);
@ -30,8 +34,6 @@ export function DashboardLayout({
setIsMobileSidebarOpen(!isMobileSidebarOpen); setIsMobileSidebarOpen(!isMobileSidebarOpen);
}; };
return ( return (
<div <div
className="h-screen flex overflow-hidden bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)] relative overflow-x-hidden" className="h-screen flex overflow-hidden bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)] relative overflow-x-hidden"
@ -55,19 +57,20 @@ export function DashboardLayout({
"fixed inset-y-0 right-0 z-50 flex flex-col lg:static lg:inset-auto lg:translate-x-0 transition-transform duration-300 ease-in-out", "fixed inset-y-0 right-0 z-50 flex flex-col lg:static lg:inset-auto lg:translate-x-0 transition-transform duration-300 ease-in-out",
isMobileSidebarOpen isMobileSidebarOpen
? "translate-x-0" ? "translate-x-0"
: "translate-x-full lg:translate-x-0", : "translate-x-full lg:translate-x-0"
)} )}
> >
<Sidebar <Sidebar
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
onToggleCollapse={toggleSidebarCollapse} onToggleCollapse={toggleSidebarCollapse}
className="h-full flex-shrink-0 relative z-10" className="h-full flex-shrink-0 relative z-10"
onStrategicAlignmentClick={() => setIsStrategicAlignmentPopupOpen(true)} onStrategicAlignmentClick={() =>
setIsStrategicAlignmentPopupOpen(true)
}
onTitleChange={(info) => { onTitleChange={(info) => {
setCurrentTitle(info.title); setCurrentTitle(info.title);
setCurrentTitleIcon(info.icon ?? null); setCurrentTitleIcon(info.icon ?? null);
}} }}
/> />
</div> </div>
@ -85,15 +88,18 @@ export function DashboardLayout({
<main <main
className={cn( className={cn(
"flex-1 overflow-x-hidden overflow-y-auto focus:outline-none transition-all duration-300 min-w-0", "flex-1 overflow-x-hidden overflow-y-auto focus:outline-none transition-all duration-300 min-w-0",
className, className
)} )}
> >
<div className="relative h-full min-w-0 w-full z-10 overflow-x-hidden"> <div className="relative h-full min-w-0 w-full z-10 overflow-x-hidden p-5">
{children} {children}
</div> </div>
</main> </main>
</div> </div>
<StrategicAlignmentPopup open={isStrategicAlignmentPopupOpen} onOpenChange={setIsStrategicAlignmentPopupOpen} /> <StrategicAlignmentPopup
open={isStrategicAlignmentPopupOpen}
onOpenChange={setIsStrategicAlignmentPopupOpen}
/>
</div> </div>
); );
} }

View File

@ -12,10 +12,10 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import moment from "moment-jalaali"; import moment from "moment-jalaali";
import { formatNumber } from "~/lib/utils"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { BaseCard } from "~/components/ui/base-card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
@ -34,8 +34,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils"; import { EventBus, formatCurrency, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { DashboardLayout } from "../layout"; import { DashboardLayout } from "../layout";
moment.loadPersian({ usePersianDigits: true }); moment.loadPersian({ usePersianDigits: true });
@ -153,7 +155,7 @@ export function DigitalInnovationPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20); const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [date, setDate] = useStoredDate();
const [actualTotalCount, setActualTotalCount] = useState(0); const [actualTotalCount, setActualTotalCount] = useState(0);
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [rating, setRating] = useState<ListItem[]>([]); const [rating, setRating] = useState<ListItem[]>([]);
@ -181,6 +183,8 @@ export function DigitalInnovationPage() {
// const [avarage, setAvarage] = useState<number>(0); // const [avarage, setAvarage] = useState<number>(0);
const observerRef = useRef<HTMLDivElement>(null); const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Selection handlers // Selection handlers
const handleSelectAll = () => { const handleSelectAll = () => {
@ -211,7 +215,7 @@ export function DigitalInnovationPage() {
value: formatNumber(stats.reduceCosts.toFixed?.(1) ?? stats.reduceCosts), value: formatNumber(stats.reduceCosts.toFixed?.(1) ?? stats.reduceCosts),
description: "میلیون ریال کاهش یافته", description: "میلیون ریال کاهش یافته",
icon: <TrendingDown />, icon: <TrendingDown />,
color: "text-emerald-400", color: "text-pr-green",
}, },
{ {
id: "bottleneck-removal", id: "bottleneck-removal",
@ -219,7 +223,7 @@ export function DigitalInnovationPage() {
value: formatNumber(stats.increasedRevenue), value: formatNumber(stats.increasedRevenue),
description: "میلیون ریال افزایش یافته", description: "میلیون ریال افزایش یافته",
icon: <TrendingUp />, icon: <TrendingUp />,
color: "text-emerald-400", color: "text-pr-green",
}, },
{ {
@ -230,7 +234,7 @@ export function DigitalInnovationPage() {
), ),
description: "هزار تن صرفه جوریی شده", description: "هزار تن صرفه جوریی شده",
icon: <Database />, icon: <Database />,
color: "text-emerald-400", color: "text-pr-green",
}, },
{ {
id: "frequent-failures-reduction", id: "frequent-failures-reduction",
@ -241,7 +245,7 @@ export function DigitalInnovationPage() {
), ),
description: "مگاوات کاهش یافته", description: "مگاوات کاهش یافته",
icon: <Zap />, icon: <Zap />,
color: "text-emerald-400", color: "text-pr-green",
}, },
]; ];
@ -280,7 +284,11 @@ export function DigitalInnovationPage() {
"reduce_costs_percent", "reduce_costs_percent",
], ],
Sorts: [[sortConfig.field, sortConfig.direction]], Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [["type_of_innovation", "=", "نوآوری دیجیتال"]], Conditions: [
["type_of_innovation", "=", "نوآوری دیجیتال", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
}); });
@ -293,16 +301,16 @@ export function DigitalInnovationPage() {
if (reset) { if (reset) {
setProjects(parsedData); setProjects(parsedData);
// calculateAverage(parsedData); // calculateAverage(parsedData);
setTotalCount(parsedData.length); // setTotalCount(parsedData.length);
} else { } else {
setProjects((prev) => [...prev, ...parsedData]); setProjects((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length); // setTotalCount((prev) => prev + parsedData.length);
} }
setHasMore(parsedData.length === pageSize); setHasMore(parsedData.length === pageSize);
} else { } else {
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -310,14 +318,14 @@ export function DigitalInnovationPage() {
console.error("Error parsing project data:", parseError); console.error("Error parsing project data:", parseError);
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
} else { } else {
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -325,7 +333,7 @@ export function DigitalInnovationPage() {
toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها"); toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها");
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -334,7 +342,7 @@ export function DigitalInnovationPage() {
toast.error("خطا در دریافت اطلاعات پروژه‌ها"); toast.error("خطا در دریافت اطلاعات پروژه‌ها");
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} finally { } finally {
@ -346,45 +354,75 @@ export function DigitalInnovationPage() {
}; };
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading, loadingMore]);
useEffect(() => { useEffect(() => {
if (date?.start && date?.end) {
fetchTable(true); fetchTable(true);
fetchTotalCount(); fetchTotalCount();
fetchStats(); fetchStats();
}, [sortConfig]); }
}, [sortConfig, date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => {
if (currentPage > 1 && date?.start && date?.end) {
fetchTable(false); fetchTable(false);
} }
}, [currentPage]); }, [currentPage]);
// Infinite scroll observer with debouncing
useEffect(() => { useEffect(() => {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = scrollContainerRef.current;
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
return;
// Clear previous timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Debounce scroll events
scrollTimeoutRef.current = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) { // Trigger load more when scrolled to 95% of the container
if (scrollPercentage >= 0.95) {
loadMore(); loadMore();
} }
}, 150);
}; };
if (scrollContainer) { if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll); scrollContainer.addEventListener("scroll", handleScroll, {
passive: true,
});
} }
return () => { return () => {
if (scrollContainer) { if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll); scrollContainer.removeEventListener("scroll", handleScroll);
} }
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
}; };
}, [loadMore, hasMore, loadingMore]); }, [loadMore, hasMore, loadingMore]);
@ -395,19 +433,23 @@ export function DigitalInnovationPage() {
direction: direction:
prev.field === field && prev.direction === "asc" ? "desc" : "asc", prev.field === field && prev.direction === "asc" ? "desc" : "asc",
})); }));
fetchTotalCount(); fetchTotalCount(date?.start, date?.end);
fetchStats(); fetchStats();
setCurrentPage(1); setCurrentPage(1);
setProjects([]); setProjects([]);
setHasMore(true); setHasMore(true);
}; };
const fetchTotalCount = async () => { const fetchTotalCount = async (startDate?: string, endDate?: string) => {
try { try {
const response = await apiService.select({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: ["count(project_no)"], OutputFields: ["count(project_no)"],
Conditions: [["type_of_innovation", "=", "نوآوری دیجیتال"]], Conditions: [
["type_of_innovation", "=", "نوآوری دیجیتال", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
if (response.state === 0) { if (response.state === 0) {
@ -434,11 +476,12 @@ export function DigitalInnovationPage() {
try { try {
setStatsLoading(true); setStatsLoading(true);
const raw = await apiService.call<any>({ const raw = await apiService.call<any>({
innovation_digital_function: {}, innovation_digital_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}); });
// let payload: DigitalInnovationMetrics = raw?.data; // let payload: DigitalInnovationMetrics = raw?.data;
// console.log("*-*-*-*" +payload); // console.log("*-*-*-*" +payload);
// if (typeof payload === "string") { // if (typeof payload === "string") {
@ -467,8 +510,6 @@ export function DigitalInnovationPage() {
} }
} }
const parseNum = (v: unknown): number => { const parseNum = (v: unknown): number => {
if (v == null) return 0; if (v == null) return 0;
if (typeof v === "number") return v; if (typeof v === "number") return v;
@ -516,33 +557,33 @@ export function DigitalInnovationPage() {
// fetchStats(); // fetchStats();
// }; // };
const renderProgress = useMemo(() => { // const renderProgress = useMemo(() => {
const total = 10; // const total = 10;
for (let i = 0; i < rating.length; i++) { // for (let i = 0; i < rating.length; i++) {
const currentElm = rating[i]; // const currentElm = rating[i];
currentElm.house = []; // currentElm.house = [];
const greenBoxes = Math.floor((total * currentElm.development) / 100); // const greenBoxes = Math.floor((total * currentElm.development) / 100);
const partialPercent = // const partialPercent =
(total * currentElm.development) / 100 - greenBoxes; // (total * currentElm.development) / 100 - greenBoxes;
for (let j = 0; j < greenBoxes; j++) { // for (let j = 0; j < greenBoxes; j++) {
currentElm.house.push({ // currentElm.house.push({
index: j, // index: j,
color: "!bg-emerald-400", // color: "!bg-emerald-400",
}); // });
} // }
if (partialPercent != 0 && greenBoxes != 10) // if (partialPercent != 0 && greenBoxes != 10)
currentElm.house.push({ // currentElm.house.push({
index: greenBoxes + 1, // index: greenBoxes + 1,
style: `linear-gradient( // style: `linear-gradient(
to right, // to right,
oklch(76.5% 0.177 163.223) 0%, // oklch(76.5% 0.177 163.223) 0%,
oklch(76.5% 0.177 163.223) ${partialPercent * 100}%, // oklch(76.5% 0.177 163.223) ${partialPercent * 100}%,
oklch(55.1% 0.027 264.364) ${partialPercent * 100}%, // oklch(55.1% 0.027 264.364) ${partialPercent * 100}%,
oklch(55.1% 0.027 264.364) 100% // oklch(55.1% 0.027 264.364) 100%
)`, // )`,
}); // });
} // }
}, [rating]); // }, [rating]);
const statusColor = (status: projectStatus): any => { const statusColor = (status: projectStatus): any => {
let el = null; let el = null;
@ -586,14 +627,14 @@ export function DigitalInnovationPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleProjectDetails(item)} onClick={() => handleProjectDetails(item)}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer" className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
> >
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
case "amount_currency_reduction": case "amount_currency_reduction":
return ( return (
<span className="font-medium text-emerald-400"> <span className="font-medium text-pr-green">
{formatCurrency(String(value))} {formatCurrency(String(value))}
</span> </span>
); );
@ -604,7 +645,9 @@ export function DigitalInnovationPage() {
</Badge> </Badge>
); );
case "title": case "title":
return <span className="font-medium text-white">{String(value)}</span>; return (
<span className="font-light text-sm text-white">{String(value)}</span>
);
case "project_status": case "project_status":
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -639,7 +682,7 @@ export function DigitalInnovationPage() {
return ( return (
<DashboardLayout title="نوآوری دیجیتال"> <DashboardLayout title="نوآوری دیجیتال">
<div className="p-6 space-y-4 grid justify-between gap-8 sm:grid-cols-1 xl:grid-cols-[40%_60%]"> <div className="space-y-4 grid justify-between gap-7 pl-6 sm:grid-cols-1 xl:grid-cols-[40%_60%]">
{/* Stats Cards */} {/* Stats Cards */}
<div className="flex flex-col gap-6 w-full mb-0"> <div className="flex flex-col gap-6 w-full mb-0">
<div className="space-y-6 w-full"> <div className="space-y-6 w-full">
@ -712,50 +755,49 @@ export function DigitalInnovationPage() {
</div> </div>
{/* Process Impacts Chart */} {/* Process Impacts Chart */}
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-lg w-full overflow-hidden h-full "> <BaseCard className="rounded-xl w-full overflow-hidden">
{/* <CardContent > */} {/* <CardContent > */}
<CustomBarChart <CustomBarChart
title="تاثیرات نوآوری دیجیتال به صورت درصد مقایسه ای" title="تاثیرات نوآوری دیجیتال به صورت درصد مقایسه ای"
loading={statsLoading} loading={statsLoading}
height="100%" // height="100%"
data={[ data={[
{ {
label: DigitalCardLabel.decreasCost, label: DigitalCardLabel.decreasCost,
value: stats.reduceCostsPercent || 0, value: stats.reduceCostsPercent || 0,
color: "bg-emerald-400", color: "bg-pr-green",
labelColor: "text-white", labelColor: "text-white",
}, },
{ {
label: DigitalCardLabel.increaseRevenue, label: DigitalCardLabel.increaseRevenue,
value: stats.increasedRevenuePercent || 0, value: stats.increasedRevenuePercent || 0,
color: "bg-emerald-400", color: "bg-pr-green",
labelColor: "text-white", labelColor: "text-white",
}, },
{ {
label: DigitalCardLabel.performance, label: DigitalCardLabel.performance,
value: stats.resourceProductivityPercent || 0, value: stats.resourceProductivityPercent || 0,
color: "bg-emerald-400", color: "bg-pr-green",
labelColor: "text-white", labelColor: "text-white",
}, },
{ {
label: DigitalCardLabel.decreaseEnergy, label: DigitalCardLabel.decreaseEnergy,
value: stats.reduceEnergyConsumptionPercent || 0, value: stats.reduceEnergyConsumptionPercent || 0,
color: "bg-emerald-400", color: "bg-pr-green",
labelColor: "text-white", labelColor: "text-white",
}, },
]} ]}
barHeight="h-5" barHeight="h-5"
showAxisLabels={true} showAxisLabels={true}
/> />
{/* </CardContent> */} </BaseCard>
</Card>
</div> </div>
{/* Data Table */} {/* Data Table */}
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-[39.7rem]"> <Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative h-full"> <div className="relative h-full">
<Table containerClassName="overflow-auto custom-scrollbar w-full h-[36.8rem] "> <Table containerClassName="overflow-auto custom-scrollbar w-full h-[calc(100vh-160px)] ">
<TableHeader> <TableHeader>
<TableRow className="bg-[#3F415A]"> <TableRow className="bg-[#3F415A]">
{columns.map((column) => ( {columns.map((column) => (
@ -934,13 +976,13 @@ export function DigitalInnovationPage() {
{/* Project Details Dialog */} {/* Project Details Dialog */}
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}> <Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-5xl max-h-[80vh] overflow-y-auto"> <DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-6xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right"> <DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
شرح پروژه شرح پروژه
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="body grid grid-cols-[40%_20%_40%]"> <div className="body grid grid-cols-[40%_20%_40%] pb-6">
<div className="border-l-2 border-l-gray-600 px-6"> <div className="border-l-2 border-l-gray-600 px-6">
<span className="title text-lg font-bold"> <span className="title text-lg font-bold">
{dialogInfo?.title} {dialogInfo?.title}
@ -992,7 +1034,7 @@ export function DigitalInnovationPage() {
</div> </div>
<div className="digitalAbilityDevelopment flex flex-col gap-10 border-l-2 border-l-gray-600 px-5"> <div className="digitalAbilityDevelopment flex flex-col gap-10 border-l-2 border-l-gray-600 px-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<span className="text-md font-bold"> <span className="text-lg font-bold">
توسعه قابلیت های دیجیتال:{" "} توسعه قابلیت های دیجیتال:{" "}
</span> </span>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -1055,10 +1097,10 @@ export function DigitalInnovationPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col pr-7 gap-4"> <div className="flex flex-col px-6 gap-4">
<div className="costBoard mx-auto w-full"> <div className="costBoard mx-auto w-full">
<div className="board o border border-gray-600 rounded-xl overflow-hidden flex flex-col"> <div className="board o border border-gray-600 rounded-xl overflow-hidden flex flex-col">
<span className="title bg-[#3F415A] text-white w-full p-2.5 pr-4 "> <span className="text-sm bg-[#3F415A] text-white w-full p-2.5 pr-4 ">
کاهش هزینه ها کاهش هزینه ها
</span> </span>

View File

@ -1,6 +1,4 @@
// import moment from "moment-jalaali";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { formatNumber } from "~/lib/utils";
import { import {
Bar, Bar,
BarChart, BarChart,
@ -27,6 +25,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { EventBus, formatNumber } from "~/lib/utils";
import { import {
Building2, Building2,
@ -43,12 +42,17 @@ import {
UsersIcon, UsersIcon,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import moment from "moment-jalaali";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { MetricCard } from "~/components/ui/metric-card";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils"; import { formatCurrency } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import DashboardLayout from "../layout"; import DashboardLayout from "../layout";
// moment.loadPersian({ usePersianDigits: true }); moment.loadPersian({ usePersianDigits: true });
interface GreenInnovationData { interface GreenInnovationData {
WorkflowID: string; WorkflowID: string;
approved_budget: string; approved_budget: string;
@ -166,6 +170,8 @@ export function GreenInnovationPage() {
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [actualTotalCount, setActualTotalCount] = useState(0); const [actualTotalCount, setActualTotalCount] = useState(0);
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [date, setDate] = useStoredDate();
const [stats, setStats] = useState<stateCounter>(); const [stats, setStats] = useState<stateCounter>();
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "start_date", field: "start_date",
@ -288,7 +294,11 @@ export function GreenInnovationPage() {
"observer", "observer",
], ],
Sorts: [[sortConfig.field, sortConfig.direction]], Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [["type_of_innovation", "=", "نوآوری سبز"]], Conditions: [
["type_of_innovation", "=", "نوآوری سبز", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
}); });
if (response.state === 0) { if (response.state === 0) {
@ -350,20 +360,34 @@ export function GreenInnovationPage() {
} }
}; };
useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading]);
useEffect(() => { useEffect(() => {
if (date.end && date.start) {
fetchProjects(true); fetchProjects(true);
fetchTotalCount(); fetchTotalCount();
}, [sortConfig]); }
}, [sortConfig, date]);
useEffect(() => { useEffect(() => {
fetchStats(); if (date.end && date.start) fetchStats();
}, [selectedProjects]); }, [selectedProjects, date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { if (currentPage > 1) {
@ -375,12 +399,12 @@ export function GreenInnovationPage() {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = document.querySelector(".overflow-auto");
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) { if (scrollPercentage == 1) {
loadMore(); loadMore();
} }
}; };
@ -416,7 +440,11 @@ export function GreenInnovationPage() {
const response = await apiService.select({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: ["count(project_no)"], OutputFields: ["count(project_no)"],
Conditions: [["type_of_innovation", "=", "نوآوری سبز"]], Conditions: [
["type_of_innovation", "=", "نوآوری سبز", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
if (response.state === 0) { if (response.state === 0) {
const dataString = response.data; const dataString = response.data;
@ -448,6 +476,8 @@ export function GreenInnovationPage() {
selectedProjects.size > 0 selectedProjects.size > 0
? Array.from(selectedProjects).join(" , ") ? Array.from(selectedProjects).join(" , ")
: "", : "",
start_date: date?.start || null,
end_date: date?.end || null,
}, },
}); });
let payload: any = raw?.data; let payload: any = raw?.data;
@ -494,13 +524,13 @@ export function GreenInnovationPage() {
}, },
pollution: { pollution: {
value: formatNumber(parseNum(stats.pollution_reduction)), value: parseNum(stats.pollution_reduction),
percent: formatNumber(parseNum(stats.pollution_reduction_percent)), percent: parseNum(stats.pollution_reduction_percent),
}, },
waste: { waste: {
value: formatNumber(parseNum(stats.waste_reduction)), value: parseNum(stats.waste_reduction),
percent: formatNumber(parseNum(stats.waste_reductionn_percent)), percent: parseNum(stats.waste_reductionn_percent),
}, },
avarage: stats.average_project_score, avarage: stats.average_project_score,
countInnovationGreenProjects: stats.count_innovation_green_projects, countInnovationGreenProjects: stats.count_innovation_green_projects,
@ -518,7 +548,6 @@ export function GreenInnovationPage() {
setStatsLoading(false); setStatsLoading(false);
} }
}; };
const setPageData = (normalized: any) => { const setPageData = (normalized: any) => {
setSustainabilityStats((prev) => ({ setSustainabilityStats((prev) => ({
...prev, ...prev,
@ -602,14 +631,14 @@ export function GreenInnovationPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleProjectDetails(item)} onClick={() => handleProjectDetails(item)}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer" className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
> >
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
case "amount_currency_reduction": case "amount_currency_reduction":
return ( return (
<span className="font-medium text-emerald-400"> <span className="font-medium text-pr-green">
{formatCurrency(String(value))} {formatCurrency(String(value))}
</span> </span>
); );
@ -620,7 +649,9 @@ export function GreenInnovationPage() {
</Badge> </Badge>
); );
case "title": case "title":
return <span className="font-medium text-white">{String(value)}</span>; return (
<span className="font-light text-sm text-white">{String(value)}</span>
);
case "project_status": case "project_status":
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -686,7 +717,7 @@ export function GreenInnovationPage() {
return ( return (
<DashboardLayout title="نوآوری سبز"> <DashboardLayout title="نوآوری سبز">
<div className="p-6 space-y-4 h-[23.5rem]"> <div className="space-y-4 h-[23.5rem]">
{/* Stats Cards */} {/* Stats Cards */}
<div className="flex gap-6 mb-5 md:flex-col xl:flex-row"> <div className="flex gap-6 mb-5 md:flex-col xl:flex-row">
<div className="flex flex-col justify-between xl:w-1/2 sm:w-full sm:gap-2"> <div className="flex flex-col justify-between xl:w-1/2 sm:w-full sm:gap-2">
@ -720,39 +751,14 @@ export function GreenInnovationPage() {
</Card> </Card>
)) ))
: Object.entries(sustainabilityStats).map(([key, value]) => ( : Object.entries(sustainabilityStats).map(([key, value]) => (
<Card <MetricCard
key={key} key={key}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50" title={value.title}
> value={Math.round(value.total.value || 0)}
<CardContent className="p-0 h-full"> valueLabel={value.total?.description}
<div className="flex flex-col justify-between gap-2 h-full"> percentValue={value.percent?.value || 0}
<div className="flex justify-between items-center border-b-2 border-gray-500/20 "> percentLabel={value.percent?.description}
<h3 className="text-lg font-bold text-white font-persian p-4"> />
{value.title}
</h3>
</div>
<div className="flex items-center justify-between p-6 flex-row-reverse">
<div className="flex flex-col">
<span className="text-3xl font-bold text-emerald-400 mb-1 font-persian">
% {value.percent?.value}
</span>
<span className="text-sm text-gray-400 font-persian">
{value.percent?.description}
</span>
</div>
<b className="block w-0.5 h-8 bg-gray-600 rotate-45" />
<div className="flex flex-col">
<span className="text-3xl font-bold text-emerald-400 mb-1 font-persian">
{value.total?.value}
</span>
<span className="text-sm text-gray-400 font-persian">
{value.total?.description}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
@ -813,7 +819,10 @@ export function GreenInnovationPage() {
<div className="params flex flex-col gap-3.5"> <div className="params flex flex-col gap-3.5">
{Object.entries(recycleParams).map((el, index) => { {Object.entries(recycleParams).map((el, index) => {
return ( return (
<div key={index} className="param flex flex-row justify-between items-center"> <div
key={index}
className="param flex flex-row justify-between items-center"
>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{el[1].icon} {el[1].icon}
<span className="font-normal text-sm font-persian"> <span className="font-normal text-sm font-persian">
@ -895,7 +904,7 @@ export function GreenInnovationPage() {
</Card> </Card>
)} )}
<Card className="w-1/3 bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-lg overflow-hidden"> <Card className="w-1/2 bg-pr-gray backdrop-blur-sm rounded-lg overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="border-b-2 border-gray-500/20"> <div className="border-b-2 border-gray-500/20">
<div className="flex flex-row justify-between w-full p-4"> <div className="flex flex-row justify-between w-full p-4">
@ -946,7 +955,7 @@ export function GreenInnovationPage() {
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden"> <Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative"> <div className="relative">
<Table containerClassName="overflow-auto custom-scrollbar h-[25rem]"> <Table containerClassName="overflow-auto custom-scrollbar h-full">
<TableHeader> <TableHeader>
<TableRow className="bg-[#3F415A]"> <TableRow className="bg-[#3F415A]">
{columns.map((column) => ( {columns.map((column) => (
@ -1110,7 +1119,7 @@ export function GreenInnovationPage() {
شرح پروژه شرح پروژه
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 flex justify-between text-right px-6"> <div className="space-y-4 flex justify-between text-right p-6">
{/* Project Description */} {/* Project Description */}
<div className="flex-[4] border-l-2 border-gray-600"> <div className="flex-[4] border-l-2 border-gray-600">
<h2 className="font-bold">{selectedProjectDetails?.title}</h2> <h2 className="font-bold">{selectedProjectDetails?.title}</h2>

View File

@ -39,8 +39,11 @@ import {
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
} from "recharts"; } from "recharts";
import { MetricCard } from "~/components/ui/metric-card";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatCurrency, formatNumber } from "~/lib/utils"; import { EventBus, formatCurrency, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import DashboardLayout from "../layout"; import DashboardLayout from "../layout";
interface innovationBuiltInDate { interface innovationBuiltInDate {
@ -152,8 +155,8 @@ enum projectStatus {
const columns = [ const columns = [
{ key: "select", label: "", sortable: false, width: "50px" }, { key: "select", label: "", sortable: false, width: "50px" },
{ key: "project_no", label: "شماره پروژه", sortable: true, width: "140px" }, { key: "project_no", label: "شماره پروژه", sortable: true, width: "120px" },
{ key: "title", label: "عنوان پروژه", sortable: true, width: "400px" }, { key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
{ {
key: "project_status", key: "project_status",
label: "وضعیت پروژه", label: "وضعیت پروژه",
@ -164,7 +167,7 @@ const columns = [
key: "project_rating", key: "project_rating",
label: "امتیاز پروژه", label: "امتیاز پروژه",
sortable: true, sortable: true,
width: "140px", width: "120px",
}, },
{ key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" }, { key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" },
]; ];
@ -191,6 +194,8 @@ export function InnovationBuiltInsidePage() {
field: "start_date", field: "start_date",
direction: "asc", direction: "asc",
}); });
const [date, setDate] = useStoredDate();
const [tblAvarage, setTblAvarage] = useState<number>(0); const [tblAvarage, setTblAvarage] = useState<number>(0);
const [selectedProjects, setSelectedProjects] = const [selectedProjects, setSelectedProjects] =
useState<Set<string | number>>(); useState<Set<string | number>>();
@ -310,7 +315,11 @@ export function InnovationBuiltInsidePage() {
"technology_maturity_level", "technology_maturity_level",
], ],
Sorts: [[sortConfig.field, sortConfig.direction]], Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [["type_of_innovation", "=", "نوآوری ساخت داخل"]], Conditions: [
["type_of_innovation", "=", "نوآوری ساخت داخل", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
}); });
if (response.state === 0) { if (response.state === 0) {
@ -411,18 +420,30 @@ export function InnovationBuiltInsidePage() {
}; };
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading]);
useEffect(() => { useEffect(() => {
fetchProjects(true); const handler = (date: CalendarDate) => {
}, [sortConfig]); if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => { useEffect(() => {
fetchStats(); if (date.start && date.end) fetchProjects(true);
}, [selectedProjects]); }, [sortConfig, date]);
useEffect(() => {
if (date.end && date.start) fetchStats();
}, [selectedProjects, date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { if (currentPage > 1) {
@ -434,12 +455,12 @@ export function InnovationBuiltInsidePage() {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = document.querySelector(".overflow-auto");
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) { if (scrollPercentage == 1) {
loadMore(); loadMore();
} }
}; };
@ -480,6 +501,8 @@ export function InnovationBuiltInsidePage() {
selectedProjects && selectedProjects?.size > 0 selectedProjects && selectedProjects?.size > 0
? Array.from(selectedProjects).join(" , ") ? Array.from(selectedProjects).join(" , ")
: "", : "",
start_date: date?.start || null,
end_date: date?.end || null,
}, },
}); });
let payload: any = raw?.data; let payload: any = raw?.data;
@ -505,15 +528,13 @@ export function InnovationBuiltInsidePage() {
const stats = data[0]; const stats = data[0];
const normalized: any = { const normalized: any = {
currencySaving: { currencySaving: {
value: formatNumber(parseNum(stats?.foreign_currency_saving)), value: parseNum(stats?.foreign_currency_saving),
percent: formatNumber( percent: parseNum(stats?.foreign_currency_saving_percent),
parseNum(stats?.foreign_currency_saving_percent)
),
}, },
investmentAmount: { investmentAmount: {
value: formatNumber(parseNum(stats?.investment_amount)), value: parseNum(stats?.investment_amount),
percent: formatNumber(parseNum(stats?.investment_amount_percent)), percent: parseNum(stats?.investment_amount_percent),
}, },
technology: { technology: {
@ -624,14 +645,14 @@ export function InnovationBuiltInsidePage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleProjectDetails(item)} onClick={() => handleProjectDetails(item)}
className="text-emerald-500 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer" className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
> >
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
case "amount_currency_reduction": case "amount_currency_reduction":
return ( return (
<span className="font-medium text-emerald-500"> <span className="font-medium text-pr-green">
{formatCurrency(String(value))} {formatCurrency(String(value))}
</span> </span>
); );
@ -642,7 +663,9 @@ export function InnovationBuiltInsidePage() {
</Badge> </Badge>
); );
case "title": case "title":
return <span className="font-medium text-white">{String(value)}</span>; return (
<span className="font-light text-sm text-white">{String(value)}</span>
);
case "project_status": case "project_status":
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -701,10 +724,10 @@ export function InnovationBuiltInsidePage() {
return ( return (
<DashboardLayout title="نوآوری ساخت داخل"> <DashboardLayout title="نوآوری ساخت داخل">
<div className="p-6 space-y-4 justify-between gap-8 grid sm:grid-cols-1 xl:grid-cols-[40%_60%]"> <div className="space-y-4 justify-between gap-8 grid pl-6 sm:grid-cols-1 xl:grid-cols-[35%_65%]">
{/* Stats Cards */} {/* Stats Cards */}
<div className="flex gap-6 w-full mb-0"> <div className="flex w-full mb-0">
<div className="flex flex-col justify-between w-full gap-6"> <div className="flex flex-col w-full justify-between gap-2">
{statsLoading {statsLoading
? // Loading skeleton for stats cards - matching new design ? // Loading skeleton for stats cards - matching new design
Array.from({ length: 2 }).map((_, index) => ( Array.from({ length: 2 }).map((_, index) => (
@ -735,39 +758,47 @@ export function InnovationBuiltInsidePage() {
</Card> </Card>
)) ))
: Object.entries(sustainabilityStats).map(([key, value]) => ( : Object.entries(sustainabilityStats).map(([key, value]) => (
<Card <MetricCard
key={key} key={key}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50" title={value.title}
> value={Math.round(value.total.value || 0)}
<CardContent className="p-0 h-full"> valueLabel={value.total?.description}
<div className="flex flex-col justify-between gap-2 h-full"> percentValue={value.percent?.value || 0}
<div className="flex justify-between items-center border-b-2 border-gray-500/20 "> percentLabel={value.percent?.description}
<h3 className="text-lg font-semibold text-white p-4"> />
{value.title} // <Card
</h3> // key={key}
</div> // className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50"
<div className="flex items-center justify-between p-6 flex-row-reverse"> // >
<div className="flex flex-col"> // <CardContent className="p-0 h-full">
<span className="text-3xl font-bold text-emerald-500 mb-1 font-persian"> // <div className="flex flex-col justify-between gap-2 h-full">
% {value.percent?.value} // <div className="flex justify-between items-center border-b-2 border-gray-500/20 ">
</span> // <h3 className="text-lg font-semibold text-white p-4">
<span className="text-sm text-gray-400 font-persian"> // {value.title}
{value.percent?.description} // </h3>
</span> // </div>
</div> // <div className="flex items-center justify-between p-6 flex-row-reverse">
<b className="block w-0.5 h-8 bg-gray-600 rotate-45" /> // <div className="flex flex-col">
<div className="flex flex-col"> // <span className="text-3xl font-bold text-pr-green mb-1 font-persian">
<span className="text-3xl font-bold text-emerald-500 mb-1 font-persian"> // % {value.percent?.value}
{value.total?.value} // </span>
</span> // <span className="text-sm text-gray-400 font-persian">
<span className="text-sm text-gray-400 font-persian"> // {value.percent?.description}
{value.total?.description} // </span>
</span> // </div>
</div> // <b className="block w-0.5 h-8 bg-gray-600 rotate-45" />
</div> // <div className="flex flex-col">
</div> // <span className="text-3xl font-bold text-pr-green mb-1 font-persian">
</CardContent> // {value.total?.value}
</Card> // </span>
// <span className="text-sm text-gray-400 font-persian">
// {value.total?.description}
// </span>
// </div>
// </div>
// </div>
// </CardContent>
// </Card>
))} ))}
{statsLoading ? ( {statsLoading ? (
@ -867,7 +898,7 @@ export function InnovationBuiltInsidePage() {
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max"> <Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative "> <div className="relative ">
<Table containerClassName="overflow-auto custom-scrollbar h-[calc(90vh-15px)]"> <Table containerClassName="overflow-auto custom-scrollbar h-[calc(100vh-160px)]">
<TableHeader> <TableHeader>
<TableRow className="bg-[#3F415A]"> <TableRow className="bg-[#3F415A]">
{columns.map((column) => ( {columns.map((column) => (
@ -1030,7 +1061,7 @@ export function InnovationBuiltInsidePage() {
{/* Project Details Dialog */} {/* Project Details Dialog */}
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}> <Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-6xl overflow-y-auto"> <DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-5xl overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right"> <DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
شرح پروژه شرح پروژه
@ -1092,7 +1123,6 @@ export function InnovationBuiltInsidePage() {
<div className="flex flex-col justify-center items-center"> <div className="flex flex-col justify-center items-center">
<span className="block w-0.5 h-14 bg-white"></span> <span className="block w-0.5 h-14 bg-white"></span>
<span className="text-white border border-white p-1 px-2 text-xs rounded-lg"> <span className="text-white border border-white p-1 px-2 text-xs rounded-lg">
{" "}
سطح تکنولوژی سطح تکنولوژی
</span> </span>
</div> </div>
@ -1256,7 +1286,7 @@ export function InnovationBuiltInsidePage() {
))} ))}
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height={420}> <ResponsiveContainer width="100%" height={400}>
<LineChart <LineChart
data={dialogChartData} data={dialogChartData}
margin={{ top: 20, right: 70, left: 30, bottom: 80 }} margin={{ top: 20, right: 70, left: 30, bottom: 80 }}

View File

@ -15,8 +15,9 @@ import moment from "moment-jalaali";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { BaseCard } from "~/components/ui/base-card"; import { BaseCard } from "~/components/ui/base-card";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
import { CustomBarChart } from "~/components/ui/custom-bar-chart"; import { CustomBarChart } from "~/components/ui/custom-bar-chart";
import { import {
@ -33,10 +34,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatNumber } from "~/lib/utils"; import { EventBus, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { DashboardLayout } from "../layout"; import { DashboardLayout } from "../layout";
import { Card , CardContent} from "~/components/ui/card";
moment.loadPersian({ usePersianDigits: true }); moment.loadPersian({ usePersianDigits: true });
interface ProcessInnovationData { interface ProcessInnovationData {
@ -65,9 +67,11 @@ interface ProjectStats {
percent_reduction_value_currency: string; percent_reduction_value_currency: string;
percent_sum_stopping_production: string; percent_sum_stopping_production: string;
percent_throat_removal: string; percent_throat_removal: string;
percent_operating_cost_before_innovation: string;
sum_reducing_breakdowns: number; sum_reducing_breakdowns: number;
sum_reduction_value_currency: number; sum_reduction_value_currency: number;
sum_stopping_production: number; sum_stopping_production: number;
sum_operating_cost_reduction: number;
} }
interface SortConfig { interface SortConfig {
@ -92,9 +96,11 @@ interface InnovationStats {
currencyReductionSum: number; // مجموع کاهش ارز بری (میلیون ریال) currencyReductionSum: number; // مجموع کاهش ارز بری (میلیون ریال)
frequentFailuresReductionSum: number; // مجموع کاهش خرابی های پرتکرار frequentFailuresReductionSum: number; // مجموع کاهش خرابی های پرتکرار
percentProductionStops: number | string; // درصد مقایسه‌ای جلوگیری از توقفات تولید percentProductionStops: number | string; // درصد مقایسه‌ای جلوگیری از توقفات تولید
reductionCostOprationSum: number; // مجموع کاهش هزینه عملیاتی
percentBottleneckRemoval: number | string; // درصد مقایسه‌ای رفع گلوگاه percentBottleneckRemoval: number | string; // درصد مقایسه‌ای رفع گلوگاه
percentCurrencyReduction: number | string; // درصد مقایسه‌ای کاهش ارز بری percentCurrencyReduction: number | string; // درصد مقایسه‌ای کاهش ارز بری
percentFailuresReduction: number | string; // درصد مقایسه‌ای کاهش خرابی‌های پرتکرار percentFailuresReduction: number | string; // درصد مقایسه‌ای کاهش خرابی‌های پرتکرار
percentOperatingCostBeforeInnovation: number | string; // درصد مقایسه‌ای کاهش هزینه عملیاتی
} }
const columns = [ const columns = [
@ -123,13 +129,14 @@ export function ProcessInnovationPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20); const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [date, setDate] = useStoredDate();
const [actualTotalCount, setActualTotalCount] = useState(0); const [actualTotalCount, setActualTotalCount] = useState(0);
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [stats, setStats] = useState<InnovationStats>({ const [stats, setStats] = useState<InnovationStats>({
totalProjects: 0, totalProjects: 0,
averageScore: 0, averageScore: 0,
productionStopsPreventionSum: 0, productionStopsPreventionSum: 0,
reductionCostOprationSum: 0,
bottleneckRemovalCount: 0, bottleneckRemovalCount: 0,
currencyReductionSum: 0, currencyReductionSum: 0,
frequentFailuresReductionSum: 0, frequentFailuresReductionSum: 0,
@ -137,6 +144,7 @@ export function ProcessInnovationPage() {
percentBottleneckRemoval: 0, percentBottleneckRemoval: 0,
percentCurrencyReduction: 0, percentCurrencyReduction: 0,
percentFailuresReduction: 0, percentFailuresReduction: 0,
percentOperatingCostBeforeInnovation: 0,
}); });
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "start_date", field: "start_date",
@ -152,58 +160,60 @@ export function ProcessInnovationPage() {
const [stateCard, setStateCard] = useState({ const [stateCard, setStateCard] = useState({
productionstopsprevention: { productionstopsprevention: {
id: "productionstopsprevention", id: "productionstopsprevention",
title: "جلوگیری از توقفات تولید", title: "توقفات تولید",
value: formatNumber( value: formatNumber(
stats.productionStopsPreventionSum.toFixed?.(1) ?? stats.productionStopsPreventionSum.toFixed?.(1) ??
stats.productionStopsPreventionSum stats.productionStopsPreventionSum
), ),
description: "تن افزایش یافته", description: "تن افزایش یافته",
icon: CirclePause, icon: CirclePause,
color: "text-emerald-400", color: "text-pr-green",
}, },
bottleneckremoval: { bottleneckremoval: {
id: "bottleneckremoval", id: "bottleneckremoval",
title: "رفع گلوگاه", title: "گلوگاه ها",
value: formatNumber(stats.bottleneckRemovalCount), value: formatNumber(stats.bottleneckRemovalCount),
description: "تعداد رفع گلوگاه", description: "تعداد رفع گلوگاه",
icon: Funnel, icon: Funnel,
color: "text-emerald-400", color: "text-pr-green",
}, },
currencyreduction: { currencyreduction: {
id: "currencyreduction", id: "currencyreduction",
title: "کاهش ارز بری", title: "ارز بری",
value: formatNumber( value: formatNumber(
stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum
), ),
description: "دلار کاهش یافته", description: "دلار کاهش یافته",
icon: DollarSign, icon: DollarSign,
color: "text-emerald-400", color: "text-pr-green",
},
decreaseCurrencyOperation: {
id: "decreaseCurrencyOperation",
title: "هزینه های عملیاتی",
value: formatNumber(
stats.reductionCostOprationSum.toFixed?.(0) ??
stats.reductionCostOprationSum
),
description: "میلیون ریال کاهش یافته",
icon: DollarSign,
color: "text-pr-green",
}, },
frequentfailuresreduction: { frequentfailuresreduction: {
id: "frequentfailuresreduction", id: "frequentfailuresreduction",
title: "کاهش خرابی های پرتکرار", title: "خرابی های پرتکرار",
value: formatNumber( value: formatNumber(
stats.frequentFailuresReductionSum.toFixed?.(1) ?? stats.frequentFailuresReductionSum.toFixed?.(1) ??
stats.frequentFailuresReductionSum stats.frequentFailuresReductionSum
), ),
description: "مجموع درصد کاهش خرابی", description: "خرابی پر تکرار کاهش یافته",
icon: Wrench, icon: Wrench,
color: "text-emerald-400", color: "text-pr-green",
}, },
}); });
const observerRef = useRef<HTMLDivElement>(null); const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
// Selection handlers
const handleSelectAll = () => {
if (selectedProjects.size === projects.length) {
setSelectedProjects(new Set());
} else {
setSelectedProjects(new Set(projects.map((p) => p.project_no)));
}
};
const handleSelectProject = (projectNo: string) => { const handleSelectProject = (projectNo: string) => {
const newSelected = new Set(selectedProjects); const newSelected = new Set(selectedProjects);
if (newSelected.has(projectNo)) { if (newSelected.has(projectNo)) {
@ -256,11 +266,14 @@ export function ProcessInnovationPage() {
"observer", "observer",
], ],
Sorts: [["start_date", "asc"]], Sorts: [["start_date", "asc"]],
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]], Conditions: [
["type_of_innovation", "=", "نوآوری در فرآیند", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
}); });
console.log(JSON.parse(response.data));
if (response.state === 0) { if (response.state === 0) {
const dataString = response.data; const dataString = response.data;
if (dataString && typeof dataString === "string") { if (dataString && typeof dataString === "string") {
@ -269,16 +282,16 @@ export function ProcessInnovationPage() {
if (Array.isArray(parsedData)) { if (Array.isArray(parsedData)) {
if (reset) { if (reset) {
setProjects(parsedData); setProjects(parsedData);
setTotalCount(parsedData.length); // setTotalCount(parsedData.length);
} else { } else {
setProjects((prev) => [...prev, ...parsedData]); setProjects((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length); // setTotalCount((prev) => prev + parsedData.length);
} }
setHasMore(parsedData.length === pageSize); setHasMore(parsedData.length === pageSize);
} else { } else {
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -286,14 +299,14 @@ export function ProcessInnovationPage() {
console.error("Error parsing project data:", parseError); console.error("Error parsing project data:", parseError);
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
} else { } else {
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -301,7 +314,7 @@ export function ProcessInnovationPage() {
toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها"); toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها");
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} }
@ -310,7 +323,7 @@ export function ProcessInnovationPage() {
toast.error("خطا در دریافت اطلاعات پروژه‌ها"); toast.error("خطا در دریافت اطلاعات پروژه‌ها");
if (reset) { if (reset) {
setProjects([]); setProjects([]);
setTotalCount(0); // setTotalCount(0);
} }
setHasMore(false); setHasMore(false);
} finally { } finally {
@ -321,19 +334,33 @@ export function ProcessInnovationPage() {
}; };
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading]);
useEffect(() => { useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => {
if (date?.start && date?.end) {
fetchProjects(true); fetchProjects(true);
fetchTotalCount(); fetchTotalCount();
}, [sortConfig]); }
}, [sortConfig, date]);
useEffect(() => { useEffect(() => {
fetchStats(); if (date?.start && date?.end) fetchStats();
}, [selectedProjects]); }, [selectedProjects, date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { if (currentPage > 1) {
@ -345,12 +372,12 @@ export function ProcessInnovationPage() {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = document.querySelector(".overflow-auto");
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) { if (scrollPercentage == 1) {
loadMore(); loadMore();
} }
}; };
@ -383,7 +410,11 @@ export function ProcessInnovationPage() {
const response = await apiService.select({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: ["count(project_no)"], OutputFields: ["count(project_no)"],
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]], Conditions: [
["type_of_innovation", "=", "نوآوری در فرآیند", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
if (response.state === 0) { if (response.state === 0) {
@ -417,6 +448,8 @@ export function ProcessInnovationPage() {
selectedProjects.size > 0 selectedProjects.size > 0
? Array.from(selectedProjects).join(" , ") ? Array.from(selectedProjects).join(" , ")
: "", : "",
start_date: date?.start || null,
end_date: date?.end || null,
}, },
}); });
@ -447,10 +480,13 @@ export function ProcessInnovationPage() {
totalProjects: parseNum(stats?.count_innovation_process_projects), totalProjects: parseNum(stats?.count_innovation_process_projects),
averageScore: parseFloat(data[0].average_project_score), averageScore: parseFloat(data[0].average_project_score),
productionStopsPreventionSum: parseNum(stats?.sum_stopping_production), productionStopsPreventionSum: parseNum(stats?.sum_stopping_production),
reductionCostOprationSum: parseNum(stats?.sum_operating_cost_reduction),
bottleneckRemovalCount: parseNum(stats?.count_throat_removal), bottleneckRemovalCount: parseNum(stats?.count_throat_removal),
currencyReductionSum: parseNum(stats?.sum_reduction_value_currency), currencyReductionSum: parseNum(stats?.sum_reduction_value_currency),
frequentFailuresReductionSum: parseNum(stats?.sum_reducing_breakdowns), frequentFailuresReductionSum: parseNum(stats?.sum_reducing_breakdowns),
percentProductionStops: stats?.percent_sum_stopping_production, percentProductionStops: stats?.percent_sum_stopping_production,
percentOperatingCostBeforeInnovation:
stats?.percent_operating_cost_before_innovation,
percentBottleneckRemoval: stats?.percent_throat_removal, percentBottleneckRemoval: stats?.percent_throat_removal,
percentCurrencyReduction: stats?.percent_reduction_value_currency, percentCurrencyReduction: stats?.percent_reduction_value_currency,
percentFailuresReduction: stats?.percent_reducing_breakdowns, percentFailuresReduction: stats?.percent_reducing_breakdowns,
@ -473,6 +509,10 @@ export function ProcessInnovationPage() {
...prev.currencyreduction, ...prev.currencyreduction,
value: formatNumber(normalized.currencyReductionSum), value: formatNumber(normalized.currencyReductionSum),
}, },
decreaseCurrencyOperation: {
...prev.decreaseCurrencyOperation,
value: formatNumber(normalized.reductionCostOprationSum),
},
})); }));
setStats(normalized); setStats(normalized);
} catch (error) { } catch (error) {
@ -525,7 +565,7 @@ export function ProcessInnovationPage() {
<Checkbox <Checkbox
checked={selectedProjects.has(item.project_id)} checked={selectedProjects.has(item.project_id)}
onCheckedChange={() => handleSelectProject(item.project_id)} onCheckedChange={() => handleSelectProject(item.project_id)}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600" className="data-[state=checked]:bg-pr-green data-[state=checked]:border-pr-green"
/> />
); );
case "details": case "details":
@ -534,14 +574,14 @@ export function ProcessInnovationPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleProjectDetails(item)} onClick={() => handleProjectDetails(item)}
className="text-pr-green hover:text-emerald-300 underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto" className="text-pr-green underline-offset-4 underline font-normal p-2 h-auto"
> >
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
case "amount_currency_reduction": case "amount_currency_reduction":
return ( return (
<span className="font-medium text-emerald-400"> <span className="font-medium text-pr-green">
{formatCurrency(String(value))} {formatCurrency(String(value))}
</span> </span>
); );
@ -552,7 +592,11 @@ export function ProcessInnovationPage() {
</Badge> </Badge>
); );
case "title": case "title":
return <span className="font-normal text-sm text-white">{String(value)}</span>; return (
<span className="font-normal text-sm text-white">
{String(value)}
</span>
);
case "project_status": case "project_status":
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -568,7 +612,10 @@ export function ProcessInnovationPage() {
); );
case "project_rating": case "project_rating":
return ( return (
<Badge variant="outline" className="text-base font-semibold text-center border-none"> <Badge
variant="outline"
className="text-base font-semibold text-center border-none"
>
{formatNumber(String(value))} {formatNumber(String(value))}
</Badge> </Badge>
); );
@ -587,23 +634,27 @@ export function ProcessInnovationPage() {
return ( return (
<DashboardLayout title="نوآوری در فرآیند"> <DashboardLayout title="نوآوری در فرآیند">
<div className="p-6 py-2 space-y-4"> <div className="flex flex-col gap-4">
{/* Stats Cards */} {/* Stats Cards */}
<div className="flex gap-6"> <div className="flex gap-4">
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 gap-3"> <div className="h-full">
{loading || statsLoading {loading || statsLoading ? (
? // Loading skeleton for stats cards - matching new design // Skeleton cards
Array.from({ length: 4 }).map((_, index) => ( <div className="flex flex-wrap justify-between gap-3">
<BaseCard key={`skeleton-${index}`} className="rounded-2xl overflow-hidden"> {Array.from({ length: 6 }).map((_, index) => (
<BaseCard
key={`skeleton-${index}`}
className="rounded-2xl overflow-hidden w-full sm:w-[48%] md:w-[30%]"
>
<div className="flex flex-col justify-between gap-2"> <div className="flex flex-col justify-between gap-2">
<div className="flex justify-between items-center border-b-2 mx-4 border-gray-500/20"> <div className="flex justify-between items-center border-b-2 mx-4 border-gray-500/20">
<div <div
className="h-6 bg-gray-600 rounded animate-pulse" className="h-6 bg-gray-600 rounded animate-pulse"
style={{ width: "60%" }} style={{ width: "60%" }}
/> />
<div className="p-3 bg-emerald-500/20 rounded-full w-fit"> <div className="p-3 rounded-full w-fit">
<div className="w-6 h-6 bg-gray-600 rounded animate-pulse" /> <div className="w-6 h-6 bg-gray-600 rounded animate-pulse" />
</div> </div>
</div> </div>
@ -619,50 +670,129 @@ export function ProcessInnovationPage() {
</div> </div>
</div> </div>
</BaseCard> </BaseCard>
)) ))}
: Object.entries(stateCard).map(([key, card]) => { </div>
// map percent values for each card key ) : (
const percentMap: Record<string, number | string | undefined> = { <div className="flex flex-col h-full gap-5">
productionstopsprevention: stats.percentProductionStops, <div className="flex flex-row gap-4 h-full">
bottleneckremoval: stats.percentBottleneckRemoval,
currencyreduction: stats.percentCurrencyReduction,
frequentfailuresreduction: stats.percentFailuresReduction,
};
const percentValue = percentMap[key];
return (
<BaseCard <BaseCard
key={card.id} key={stateCard.productionstopsprevention.id}
title={card.title} title={stateCard.productionstopsprevention.title}
className="border-gray-700/50" className="border-gray-700/50 w-full"
icon={card.icon} icon={stateCard.productionstopsprevention.icon}
> >
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<p className="text-3xl text-pr-green font-bold mb-1"> <p className="text-3xl text-pr-green font-bold mb-1">
{(card.value)} {stateCard.productionstopsprevention.value}
</p> </p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian"> <div className="text-[11px] text-[#ACACAC] font-light font-persian">
{card.description} {stateCard.productionstopsprevention.description}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</BaseCard> </BaseCard>
);
})} <BaseCard
key={stateCard.frequentfailuresreduction.id}
title={stateCard.frequentfailuresreduction.title}
className="border-gray-700/50 w-full"
icon={stateCard.frequentfailuresreduction.icon}
>
<div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4">
<div className="text-center">
<p className="text-3xl text-pr-green font-bold mb-1">
{stateCard.frequentfailuresreduction.value}
</p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian">
{stateCard.frequentfailuresreduction.description}
</div>
</div>
</div>
</div>
</BaseCard>
</div>
<div className="flex flex-row gap-4 h-full">
<BaseCard
key={stateCard.currencyreduction.id}
title={stateCard.currencyreduction.title}
className="border-gray-700/50 w-full"
icon={stateCard.currencyreduction.icon}
>
<div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4">
<div className="text-center">
<p className="text-3xl text-pr-green font-bold mb-1">
{stateCard.currencyreduction.value}
</p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian">
{stateCard.currencyreduction.description}
</div>
</div>
</div>
</div>
</BaseCard>
<BaseCard
key={stateCard.decreaseCurrencyOperation.id}
title={stateCard.decreaseCurrencyOperation.title}
className="border-gray-700/50 w-full"
icon={stateCard.decreaseCurrencyOperation.icon}
>
<div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4">
<div className="text-center">
<p className="text-3xl text-pr-green font-bold mb-1">
{stateCard.decreaseCurrencyOperation.value}
</p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian">
{stateCard.decreaseCurrencyOperation.description}
</div>
</div>
</div>
</div>
</BaseCard>
<BaseCard
key={stateCard.bottleneckremoval.id}
title={stateCard.bottleneckremoval.title}
className="border-gray-700/50 w-full"
icon={stateCard.bottleneckremoval.icon}
>
<div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4">
<div className="text-center">
<p className="text-3xl text-pr-green font-bold mb-1">
{stateCard.bottleneckremoval.value}
</p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian">
{stateCard.bottleneckremoval.description}
</div>
</div>
</div>
</div>
</BaseCard>
</div>
</div>
)}
</div> </div>
</div> </div>
{/* Process Impacts Chart */} {/* Process Impacts Chart */}
<BaseCard className="rounded-2xl w-full overflow-hidden"> {/* نمودار با الگوریتم Nice Numbers:
مثلاً اگر دادهها [10, 35, 63, 18] باشند:
- حداکثر: 63، با حاشیه 5% = 66.15
- Nice Max: 75 (گرد و خوانا)
- Ticks: [0, 20, 40, 60, 75]
این باعث میشود نمودار زیباتر و خواناتر باشد */}
<BaseCard className="rounded-xl w-full overflow-hidden">
<CustomBarChart <CustomBarChart
title="تاثیرات فرآیندی به صورت درصد مقایسه ای" title="تاثیرات فرآیندی به صورت درصد مقایسه ای"
loading={statsLoading} loading={statsLoading}
data={[ data={[
{ {
label: "کاهش توقفات تولید", label: "توقفات تولید",
value: Number(stats.percentProductionStops) || 0, value: Number(stats.percentProductionStops) || 0,
labelColor: "text-white", labelColor: "text-white",
}, },
@ -672,17 +802,23 @@ export function ProcessInnovationPage() {
labelColor: "text-white", labelColor: "text-white",
}, },
{ {
label: "کاهش ارز بری", label: "ارز بری",
value: Number(stats.percentCurrencyReduction) || 0, value: Number(stats.percentCurrencyReduction) || 0,
labelColor: "text-white", labelColor: "text-white",
}, },
{ {
label: "کاهش خرابی پر تکرار", label: "خرابی پر تکرار",
value: Number(stats.percentFailuresReduction) || 0, value: Number(stats.percentFailuresReduction) || 0,
labelColor: "text-white", labelColor: "text-white",
}, },
{
label: "هزینه های عملیاتی",
value:
Number(stats.percentOperatingCostBeforeInnovation) || 0,
labelColor: "text-white",
},
]} ]}
barHeight="h-6" barHeight="h-5"
showAxisLabels={true} showAxisLabels={true}
/> />
</BaseCard> </BaseCard>
@ -692,7 +828,7 @@ export function ProcessInnovationPage() {
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden"> <Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative"> <div className="relative">
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(90vh-400px)]"> <Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(90vh-420px)]">
<TableHeader> <TableHeader>
<TableRow className="bg-[#3F415A]"> <TableRow className="bg-[#3F415A]">
{columns.map((column) => ( {columns.map((column) => (
@ -703,14 +839,7 @@ export function ProcessInnovationPage() {
> >
{column.key === "select" ? ( {column.key === "select" ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<Checkbox <span></span>
checked={
selectedProjects.size === projects.length &&
projects.length > 0
}
onCheckedChange={handleSelectAll}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</div> </div>
) : column.sortable ? ( ) : column.sortable ? (
<button <button
@ -746,7 +875,7 @@ export function ProcessInnovationPage() {
{columns.map((column) => ( {columns.map((column) => (
<TableCell <TableCell
key={column.key} key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2" className="text-right whitespace-nowrap border-pr-green py-1 px-2"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" /> <div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
@ -779,7 +908,7 @@ export function ProcessInnovationPage() {
{columns.map((column) => ( {columns.map((column) => (
<TableCell <TableCell
key={column.key} key={column.key}
className={`text-right whitespace-nowrap border-emerald-500/20 py-1 px-2 ${column.key === "select" ? "flex justify-center items-center" : ""}`} className={`text-right whitespace-nowrap border-pr-green py-1 px-2 ${column.key === "select" ? "flex justify-center items-center" : ""}`}
> >
{renderCellContent(project, column)} {renderCellContent(project, column)}
</TableCell> </TableCell>
@ -796,7 +925,7 @@ export function ProcessInnovationPage() {
{loadingMore && ( {loadingMore && (
<div className="flex items-center justify-center py-1"> <div className="flex items-center justify-center py-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" /> <RefreshCw className="w-4 h-4 animate-spin text-pr-green" />
<span className="font-persian text-gray-300 text-xs"></span> <span className="font-persian text-gray-300 text-xs"></span>
</div> </div>
</div> </div>
@ -841,10 +970,12 @@ export function ProcessInnovationPage() {
شرح پروژه شرح پروژه
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 flex justify-between text-right px-6"> <div className="space-y-4 flex justify-between text-right p-6">
{/* Project Description */} {/* Project Description */}
<div className="flex-[4] border-l-2 border-gray-600"> <div className="flex-[4] border-l-2 border-gray-600">
<h2 className="font-bold text-base">{selectedProjectDetails?.title}</h2> <h2 className="font-bold text-base">
{selectedProjectDetails?.title}
</h2>
<p className="text-white font-normal text-base font-persian px-2 mt-2"> <p className="text-white font-normal text-base font-persian px-2 mt-2">
{selectedProjectDetails?.project_description || "-"} {selectedProjectDetails?.project_description || "-"}
</p> </p>
@ -856,7 +987,7 @@ export function ProcessInnovationPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1"> <h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<Building2 className="h-4 text-green-500 text-sm font-light" /> <Building2 className="h-4 text-pr-green text-sm font-light" />
زمان شروع: زمان شروع:
</h4> </h4>
<span className="text-white font-normal text-base font-persian"> <span className="text-white font-normal text-base font-persian">
@ -871,7 +1002,7 @@ export function ProcessInnovationPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1"> <h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<PickaxeIcon className="h-4 text-green-500 text-sm font-light" /> <PickaxeIcon className="h-4 text-pr-green text-sm font-light" />
زمان پایان: زمان پایان:
</h4> </h4>
<span className="text-white font-normal text-base font-persian"> <span className="text-white font-normal text-base font-persian">
@ -886,7 +1017,7 @@ export function ProcessInnovationPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1"> <h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<UsersIcon className="h-4 text-green-500 text-sm font-light" /> <UsersIcon className="h-4 text-pr-green text-sm font-light" />
هزینه برآورد شده: هزینه برآورد شده:
</h4> </h4>
<span className="text-white font-normal text-base font-persian"> <span className="text-white font-normal text-base font-persian">
@ -904,7 +1035,7 @@ export function ProcessInnovationPage() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1"> <h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<UserIcon className="h-4 text-green-500 text-sm font-light" /> <UserIcon className="h-4 text-pr-green text-sm font-light" />
نفر مرتبط: نفر مرتبط:
</h4> </h4>
<span className="text-white font-normal text-base font-persian"> <span className="text-white font-normal text-base font-persian">

View File

@ -1,46 +1,37 @@
import { import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
ArrowDownCircle,
ArrowUpCircle,
Building2,
ChevronDown,
ChevronUp,
CirclePause,
DollarSign,
Funnel,
Loader2,
PickaxeIcon,
RefreshCw,
TrendingUp,
UserIcon,
UsersIcon,
Wrench,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Bar, BarChart, LabelList } from "recharts";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { MetricCard } from "~/components/ui/metric-card";
import { BaseCard } from "~/components/ui/base-card"; import { BaseCard } from "~/components/ui/base-card";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
import { Bar, BarChart, LabelList } from "recharts" import { MetricCard } from "~/components/ui/metric-card";
import { import {
Popover, Popover,
PopoverTrigger,
PopoverContent, PopoverContent,
} from "~/components/ui/popover" PopoverTrigger,
} from "~/components/ui/popover";
import { FunnelChart } from "~/components/ui/funnel-chart"; import {
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Label } from "~/components/ui/label"; import { FunnelChart } from "~/components/ui/funnel-chart";
import { Skeleton } from "~/components/ui/skeleton";
import { import {
Table, Table,
TableBody, TableBody,
@ -49,18 +40,19 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { Tooltip as TooltipSh, TooltipTrigger } from "~/components/ui/tooltip";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatNumber, handleDataValue } from "~/lib/utils"; import { EventBus, formatNumber, handleDataValue } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { DashboardLayout } from "../layout"; import { DashboardLayout } from "../layout";
import { Skeleton } from "~/components/ui/skeleton";
import { Tooltip as TooltipSh, TooltipTrigger, TooltipContent } from "~/components/ui/tooltip";
interface ProjectData { interface ProjectData {
project_no: string; project_no: string;
project_id: string; project_id: string;
title: string; title: string;
project_status: string; project_status: string;
current_status?: string;
project_rating: string; project_rating: string;
project_description: string; project_description: string;
developed_technology_type: string; developed_technology_type: string;
@ -94,6 +86,7 @@ interface ProductInnovationData {
title: string; title: string;
project_status: projectStatus; project_status: projectStatus;
project_rating: string; project_rating: string;
current_status?: string;
project_description: string; project_description: string;
developed_technology_type: string; developed_technology_type: string;
obtained_standard_title: string; obtained_standard_title: string;
@ -137,11 +130,16 @@ const columns = [
{ key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" }, { key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" },
]; ];
export default function Timeline(valueTimeLine: string) {
export default function Timeline() {
const stages = ["تجاری سازی", "توسعه", "تحلیل بازار", "ثبت ایده"]; const stages = ["تجاری سازی", "توسعه", "تحلیل بازار", "ثبت ایده"];
const currentStage = 1; // index of current stage const currentStage = stages
?.toReversed()
?.findIndex((x: string) => x == valueTimeLine);
const per = () => {
const main = stages?.findIndex((x) => x == "ثبت ایده");
console.log("yay ", 25 * main + 12.5);
return 25 * main + 12.5;
};
return ( return (
<div className="w-full p-4"> <div className="w-full p-4">
{/* Year labels */} {/* Year labels */}
@ -151,16 +149,20 @@ export default function Timeline() {
<span>۱۴۰۵</span> <span>۱۴۰۵</span>
<span>۱۴۰۴</span> <span>۱۴۰۴</span>
</div> </div>
{/* Timeline bar */} {/* Timeline bar */}
<div className="relative rounded-lg flex mb-4 items-center"> <div className="relative rounded-lg flex mb-4 items-center">
{stages.map((stage, index) => ( {stages.map((stage, index) => (
<div key={stage} className="flex-1 flex flex-col items-center relative"> <div
key={stage}
className="flex-1 flex flex-col items-center relative"
>
<TooltipSh> <TooltipSh>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className={`w-full py-2 text-center transition-colors duration-300 ${ className={`w-full py-2 text-center transition-colors duration-300 ${
index <= currentStage ? "bg-[#3D7968] text-white" : "bg-[#3AEA83] text-slate-600" index <= currentStage
? "bg-[#3D7968] text-white"
: "bg-[#3AEA83] text-slate-600"
}`} }`}
> >
<span className="mt-1 text-sm">{stage}</span> <span className="mt-1 text-sm">{stage}</span>
@ -171,23 +173,32 @@ export default function Timeline() {
))} ))}
{/* Vertical line showing current position */} {/* Vertical line showing current position */}
{valueTimeLine?.length > 0 && (
<>
{" "}
<div <div
className="absolute left-[37%] top-0 h-[150%] bottom-0 w-[2px] bg-white rounded-full" className={`absolute top-0 h-[150%] bottom-0 w-[2px] bg-white rounded-full`}
style={{ left: `${(currentStage + 0.5) * (100 / stages.length)}%` }} style={{
left: `${(currentStage + 0.5) * (100 / stages.length)}%`,
}}
/> />
<div <div
className="absolute top-15 h-[max-content] translate-x-[-50%] text-xs text-gray-300 border-gray-400 rounded-md border px-2 bottom-0" className="absolute top-15 h-[max-content] translate-x-[-50%] text-xs text-gray-300 border-gray-400 rounded-md border px-2 bottom-0"
style={{ left: `${(currentStage + 0.5) * (100 / stages.length)}%` }} style={{
>وضعیت فعلی</div> left: `${(currentStage + 0.5) * (100 / stages.length)}%`,
}}
>
وضعیت فعلی
</div>
</>
)}
</div> </div>
</div> </div>
); );
} }
export function ProductInnovationPage() { export function ProductInnovationPage() {
const [showPopup, setShowPopup] = useState(false); // const [showPopup, setShowPopup] = useState(false);
const [projects, setProjects] = useState<ProductInnovationData[]>([]); const [projects, setProjects] = useState<ProductInnovationData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
@ -230,11 +241,11 @@ export function ProductInnovationPage() {
revenueNewProducts: { revenueNewProducts: {
id: "revenueNewProducts", id: "revenueNewProducts",
title: "سهم از درآمد برای محصولات جدید", title: "سهم از درآمد برای محصولات جدید",
value: "0", value: 0,
description: "میلیون ریال", description: "میلیون ریال",
descriptionPercent: "درصد به کل درآمد", descriptionPercent: "درصد به کل درآمد",
color: "text-[#3AEA83]", color: "text-[#3AEA83]",
percent : "0" percent: 0,
}, },
newProductExports: { newProductExports: {
id: "newProductExports", id: "newProductExports",
@ -252,19 +263,22 @@ export function ProductInnovationPage() {
}, },
}); });
const [date, setDate] = useStoredDate();
const observerRef = useRef<HTMLDivElement>(null); const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
const handleProjectDetails = async (project: ProductInnovationData) => { const handleProjectDetails = async (project: ProductInnovationData) => {
setSelectedProjectDetails(project); setSelectedProjectDetails(project);
console.log(project)
setDetailsDialogOpen(true); setDetailsDialogOpen(true);
await fetchPopupData(project); await fetchPopupData(project, date?.start, date?.end);
}; };
const fetchPopupData = async (project: ProductInnovationData) => { const fetchPopupData = async (
project: ProductInnovationData,
startDate?: string,
endDate?: string
) => {
try { try {
setPopupLoading(true); setPopupLoading(true);
@ -272,25 +286,26 @@ export function ProductInnovationPage() {
const statsResponse = await apiService.call({ const statsResponse = await apiService.call({
innovation_product_popup_function1: { innovation_product_popup_function1: {
project_id: project.project_id project_id: project.project_id
} },
}); });
if (statsResponse.state === 0) { if (statsResponse.state === 0) {
const statsData = JSON.parse(statsResponse.data); const statsData = JSON.parse(statsResponse.data);
if (statsData.innovation_product_popup_function1 && statsData.innovation_product_popup_function1[0]) { if (
setPopupStats(JSON.parse(statsData.innovation_product_popup_function1)[0]); statsData.innovation_product_popup_function1 &&
statsData.innovation_product_popup_function1[0]
) {
setPopupStats(
JSON.parse(statsData.innovation_product_popup_function1)[0]
);
} }
} }
// Fetch export chart data // Fetch export chart data
const chartResponse = await apiService.select({ const chartResponse = await apiService.select({
ProcessName: "export_product_innovation", ProcessName: "export_product_innovation",
OutputFields: [ OutputFields: ["product_title", "full_season", "sum(export_revenue)"],
"product_title", GroupBy: ["product_title", "full_season"],
"full_season",
"sum(export_revenue)"
],
GroupBy: ["product_title", "full_season"]
}); });
if (chartResponse.state === 0) { if (chartResponse.state === 0) {
const chartData = JSON.parse(chartResponse.data); const chartData = JSON.parse(chartResponse.data);
@ -298,14 +313,13 @@ export function ProductInnovationPage() {
// Set all data for line chart // Set all data for line chart
// Filter data for the selected project (bar chart) // Filter data for the selected project (bar chart)
const filteredData = chartData.filter(item => const filteredData = chartData.filter(
item.product_title === project?.title (item) => item.product_title === project?.title
); );
setAllExportData(chartData); setAllExportData(chartData);
setExportChartData(filteredData); setExportChartData(filteredData);
} }
} }
} catch (error) { } catch (error) {
console.error("Error fetching popup data:", error); console.error("Error fetching popup data:", error);
} finally { } finally {
@ -314,10 +328,10 @@ export function ProductInnovationPage() {
}; };
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading]);
const fetchProjects = async (reset = false) => { const fetchProjects = async (reset = false) => {
if (fetchingRef.current) { if (fetchingRef.current) {
@ -343,6 +357,7 @@ export function ProductInnovationPage() {
"project_no", "project_no",
"title", "title",
"project_status", "project_status",
"current_status",
"project_rating", "project_rating",
"project_description", "project_description",
"developed_technology_type", "developed_technology_type",
@ -353,7 +368,11 @@ export function ProductInnovationPage() {
"issuing_authority", "issuing_authority",
], ],
Sorts: [["start_date", "asc"]], Sorts: [["start_date", "asc"]],
Conditions: [["type_of_innovation", "=", "نوآوری در محصول"]], Conditions: [
["type_of_innovation", "=", "نوآوری در محصول", "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
}); });
@ -419,8 +438,12 @@ export function ProductInnovationPage() {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
setStatsLoading(true); setStatsLoading(true);
const raw = await apiService.call<any>({ const raw = await apiService.call<any>({
innovation_product_function: {}, innovation_product_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}); });
let payload: any = JSON.parse(raw?.data); let payload: any = JSON.parse(raw?.data);
@ -436,21 +459,25 @@ export function ProductInnovationPage() {
return 0; return 0;
}; };
const data: Array<any> = JSON.parse( const data: Array<any> = JSON.parse(payload?.innovation_product_function);
payload?.innovation_product_function
);
const stats = data[0]; const stats = data[0];
const normalized: ProductInnovationStats = { const normalized: ProductInnovationStats = {
new_products_revenue_share: parseNum(stats?.new_products_revenue_share), new_products_revenue_share: parseNum(stats?.new_products_revenue_share),
new_products_revenue_share_percent: parseNum(stats?.new_products_revenue_share_percent), new_products_revenue_share_percent: parseNum(
stats?.new_products_revenue_share_percent
),
import_impact: parseNum(stats?.import_impact), import_impact: parseNum(stats?.import_impact),
new_products_export: parseNum(stats?.new_products_export), new_products_export: parseNum(stats?.new_products_export),
all_funnel: parseNum(stats?.all_funnel), all_funnel: parseNum(stats?.all_funnel),
successful_sample_funnel: parseNum(stats?.successful_sample_funnel), successful_sample_funnel: parseNum(stats?.successful_sample_funnel),
successful_products_funnel: parseNum(stats?.successful_products_funnel), successful_products_funnel: parseNum(stats?.successful_products_funnel),
successful_improvement_or_change_funnel: parseNum(stats?.successful_improvement_or_change_funnel), successful_improvement_or_change_funnel: parseNum(
stats?.successful_improvement_or_change_funnel
),
new_product_funnel: parseNum(stats?.new_product_funnel), new_product_funnel: parseNum(stats?.new_product_funnel),
count_innovation_construction_inside_projects: parseNum(stats?.count_innovation_construction_inside_projects), count_innovation_construction_inside_projects: parseNum(
stats?.count_innovation_construction_inside_projects
),
average_project_score: parseNum(stats?.average_project_score), average_project_score: parseNum(stats?.average_project_score),
}; };
@ -458,8 +485,8 @@ export function ProductInnovationPage() {
...prev, ...prev,
revenueNewProducts: { revenueNewProducts: {
...prev.revenueNewProducts, ...prev.revenueNewProducts,
value: formatNumber(normalized.new_products_revenue_share), value: normalized.new_products_revenue_share,
percent: formatNumber(normalized.new_products_revenue_share_percent), percent: normalized.new_products_revenue_share_percent,
}, },
impactOnImports: { impactOnImports: {
...prev.impactOnImports, ...prev.impactOnImports,
@ -480,12 +507,20 @@ export function ProductInnovationPage() {
}; };
useEffect(() => { useEffect(() => {
fetchProjects(true); EventBus.on("dateSelected", (date: CalendarDate) => {
}, [sortConfig]); if (date) {
setDate(date);
}
});
}, []);
useEffect(() => { useEffect(() => {
fetchStats(); if (date.end && date.start) fetchProjects(true);
}, []); }, [sortConfig, date]);
useEffect(() => {
if (date.end && date.start) fetchStats();
}, [date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { if (currentPage > 1) {
@ -497,12 +532,12 @@ export function ProductInnovationPage() {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = document.querySelector(".overflow-auto");
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) { if (scrollPercentage == 1) {
loadMore(); loadMore();
} }
}; };
@ -516,7 +551,7 @@ export function ProductInnovationPage() {
scrollContainer.removeEventListener("scroll", handleScroll); scrollContainer.removeEventListener("scroll", handleScroll);
} }
}; };
}, [loadMore, hasMore, loadingMore]); }, [loadMore, hasMore]);
const handleSort = (field: string) => { const handleSort = (field: string) => {
fetchingRef.current = false; fetchingRef.current = false;
@ -530,39 +565,42 @@ export function ProductInnovationPage() {
setHasMore(true); setHasMore(true);
}; };
// const formatCurrency = (amount: string | number) => {
const formatCurrency = (amount: string | number) => { // if (!amount) return "0 ریال";
if (!amount) return "0 ریال"; // const numericAmount =
const numericAmount = // typeof amount === "string"
typeof amount === "string" // ? parseFloat(amount.replace(/,/g, ""))
? parseFloat(amount.replace(/,/g, "")) // : amount;
: amount; // if (isNaN(numericAmount)) return "0 ریال";
if (isNaN(numericAmount)) return "0 ریال"; // return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; // };
};
// Transform data for line chart // Transform data for line chart
const transformDataForLineChart = (data: any[]) => { const transformDataForLineChart = (data: any[]) => {
const seasons = [...new Set(data.map(item => item.full_season))]; const seasons = [...new Set(data.map((item) => item.full_season))];
const products = [...new Set(data.map(item => item.product_title))]; const products = [...new Set(data.map((item) => item.product_title))];
return seasons.map(season => { return seasons.map((season) => {
const seasonData: any = { season }; const seasonData: any = { season };
products.forEach(product => { products.forEach((product) => {
const productData = data.find(item => const productData = data.find(
(item) =>
item.product_title === product && item.full_season === season item.product_title === product && item.full_season === season
); );
seasonData[product] = productData?.export_revenue_sum > 0 && productData ? Math.round(productData?.export_revenue_sum) : 0; seasonData[product] =
productData?.export_revenue_sum > 0 && productData
? Math.round(productData?.export_revenue_sum)
: 0;
}); });
return seasonData; return seasonData;
}); });
}; };
const getRatingColor = (rating: string | number) => { // const getRatingColor = (rating: string | number) => {
const numRating = typeof rating === "string" ? parseInt(rating) : rating; // const numRating = typeof rating === "string" ? parseInt(rating) : rating;
if (numRating >= 150) return "text-emerald-400"; // if (numRating >= 150) return "text-emerald-400";
if (numRating >= 100) return "text-blue-400"; // if (numRating >= 100) return "text-blue-400";
return "text-red-400"; // return "text-red-400";
}; // };
const statusColor = (status: projectStatus): any => { const statusColor = (status: projectStatus): any => {
let el = null; let el = null;
@ -607,23 +645,26 @@ export function ProductInnovationPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
handleProjectDetails(item)}} handleProjectDetails(item);
className="text-emerald-400 underline underline-offset-4 font-ligth text-base hover:bg-emerald-500/20 p-2 h-auto" }}
className="text-emerald-400 underline underline-offset-4 font-ligth text-sm p-2 h-auto"
> >
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
case "project_no": case "project_no":
return ( return (
<Badge variant="outline" className="font-mono text-base font-light"> <Badge variant="outline" className="font-mono text-sm font-light">
{String(value)} {String(value)}
</Badge> </Badge>
); );
case "title": case "title":
return <span className="font-light text-base text-white">{String(value)}</span>; return (
<span className="font-light text-sm text-white">{String(value)}</span>
);
case "project_status": case "project_status":
return ( return (
<div className="flex items-center text-base font-light gap-1"> <div className="flex items-center text-sm font-light gap-1">
<Badge <Badge
variant={statusColor(value as projectStatus)} variant={statusColor(value as projectStatus)}
className="font-semibold text-base border-2 p-0 block w-2 h-2 rounded-full" className="font-semibold text-base border-2 p-0 block w-2 h-2 rounded-full"
@ -644,7 +685,11 @@ export function ProductInnovationPage() {
</Badge> </Badge>
); );
default: default:
return <span className="text-white text-base font-light">{String(value) || "-"}</span>; return (
<span className="text-white text-sm font-light">
{String(value) || "-"}
</span>
);
} }
}; };
@ -659,14 +704,15 @@ export function ProductInnovationPage() {
}) })
.map((item) => ({ .map((item) => ({
label: item.full_season, label: item.full_season,
value: item.export_revenue_sum < 0 ? 0 : Math.round(item.export_revenue_sum) , value:
item.export_revenue_sum < 0 ? 0 : Math.round(item.export_revenue_sum),
})); }));
return ( return (
<DashboardLayout title="نوآوری در محصول"> <DashboardLayout title="نوآوری در محصول">
<div className="p-6 space-y-4 flex justify-center gap-4"> <div className=" flex w-full gap-4">
{/* Stats Cards */} {/* Stats Cards */}
<div className="flex flex-col gap-6"> <div className="flex flex-col flex-1 gap-6">
<div className="space-y-6 w-full"> <div className="space-y-6 w-full">
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 grid-rows-2 gap-5 h-full"> <div className="grid grid-cols-2 grid-rows-2 gap-5 h-full">
@ -711,18 +757,27 @@ export function ProductInnovationPage() {
value={stateCard.revenueNewProducts.value} value={stateCard.revenueNewProducts.value}
percentValue={stateCard.revenueNewProducts.percent} percentValue={stateCard.revenueNewProducts.percent}
valueLabel={stateCard.revenueNewProducts.description} valueLabel={stateCard.revenueNewProducts.description}
percentLabel={stateCard.revenueNewProducts.descriptionPercent} percentLabel={
stateCard.revenueNewProducts.descriptionPercent
}
/> />
</div> </div>
{/* Second card */} {/* Second card */}
<div> <div>
<BaseCard title={stateCard.newProductExports.title} className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"> <BaseCard
title={stateCard.newProductExports.title}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"
>
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<p className="text-3xl font-bold mb-1 text-pr-green">{stateCard.newProductExports.value}</p> <p className="text-3xl font-bold mb-1 text-pr-green">
<div className="text-xs text-gray-400 font-persian">{stateCard.newProductExports.description}</div> {stateCard.newProductExports.value}
</p>
<div className="text-xs text-gray-400 font-persian">
{stateCard.newProductExports.description}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -731,12 +786,19 @@ export function ProductInnovationPage() {
{/* Third card - basic BaseCard */} {/* Third card - basic BaseCard */}
<div> <div>
<BaseCard title={stateCard.impactOnImports.title} className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"> <BaseCard
title={stateCard.impactOnImports.title}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"
>
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<p className="text-3xl font-bold mb-1 text-pr-red">{stateCard.impactOnImports.value}</p> <p className="text-3xl font-bold mb-1 text-pr-red">
<div className="text-xs text-gray-400 font-persian">{stateCard.impactOnImports.description}</div> {stateCard.impactOnImports.value}
</p>
<div className="text-xs text-gray-400 font-persian">
{stateCard.impactOnImports.description}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -748,7 +810,7 @@ export function ProductInnovationPage() {
</div> </div>
{/* Funnel Chart */} {/* Funnel Chart */}
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] h-full backdrop-blur-sm rounded-2xl w-full overflow-hidden"> <Card className=" bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] h-full 2xl:h-auto pb-8 backdrop-blur-sm rounded-2xl w-full overflow-hidden">
<CardContent className="px-0 py-4"> <CardContent className="px-0 py-4">
<FunnelChart <FunnelChart
title="قيف فرآیند پروژه ها" title="قيف فرآیند پروژه ها"
@ -785,7 +847,7 @@ export function ProductInnovationPage() {
</div> </div>
{/* Data Table */} {/* Data Table */}
<Card className="bg-transparent rounded-2xl overflow-hidden"> <Card className="bg-transparent flex-2 rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative"> <div className="relative">
<Table containerClassName="overflow-auto custom-scrollbar backdrop max-h-[calc(100vh-200px)]"> <Table containerClassName="overflow-auto custom-scrollbar backdrop max-h-[calc(100vh-200px)]">
@ -894,7 +956,10 @@ export function ProductInnovationPage() {
<div className="flex gap-4 text-sm text-gray-300 font-persian justify-between sm:flex-col xl:flex-row"> <div className="flex gap-4 text-sm text-gray-300 font-persian justify-between sm:flex-col xl:flex-row">
<div className="text-center gap-2 items-center xl:w-1/3 pr-36 sm:w-full"> <div className="text-center gap-2 items-center xl:w-1/3 pr-36 sm:w-full">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
کل پروژه ها :{formatNumber(stats?.count_innovation_construction_inside_projects)} کل پروژه ها :
{formatNumber(
stats?.count_innovation_construction_inside_projects
)}
</div> </div>
</div> </div>
@ -909,7 +974,9 @@ export function ProductInnovationPage() {
<div className="text-bold text-sm text-white">میانگین :</div> <div className="text-bold text-sm text-white">میانگین :</div>
<div className="font-bold text-sm text-white"> <div className="font-bold text-sm text-white">
{formatNumber( {formatNumber(
((stats.average_project_score ?? 0) as number).toFixed?.(1) ?? 0 ((stats.average_project_score ?? 0) as number).toFixed?.(
1
) ?? 0
)} )}
</div> </div>
</div> </div>
@ -933,29 +1000,47 @@ export function ProductInnovationPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Stats Cards */} {/* Stats Cards */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-bold text-base">{selectedProjectDetails?.title}</h3> <h3 className="font-bold text-base">
<p className="py-2">{selectedProjectDetails?.project_description}</p> {selectedProjectDetails?.title}
</h3>
<p className="py-2">
{selectedProjectDetails?.project_description}
</p>
</div> </div>
<Timeline /> <Timeline
valueTimeLine={selectedProjectDetails?.current_status}
/>
{/* Technical Knowledge */} {/* Technical Knowledge */}
<div className=" rounded-lg py-2 mb-0"> <div className=" rounded-lg py-2 mb-0">
<h3 className="text-sm text-white font-semibold mb-2">دانش فنی محصول جدید</h3> <h3 className="text-sm text-white font-semibold mb-2">
دانش فنی محصول جدید
</h3>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-white font-light">توسعه درونزا</span> <span className="text-sm text-white font-light">
توسعه درونزا
</span>
<Checkbox <Checkbox
checked={selectedProjectDetails?.developed_technology_type === "توسعه درونزا"} checked={
selectedProjectDetails?.developed_technology_type ===
"توسعه درونزا"
}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600" className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-white font-light">همکاری فناورانه</span> <span className="text-sm text-white font-light">
همکاری فناورانه
</span>
<Checkbox <Checkbox
checked={selectedProjectDetails?.developed_technology_type === "همکاری فناوری"} checked={
selectedProjectDetails?.developed_technology_type ===
"همکاری فناوری"
}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600" className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/> />
</div> </div>
@ -963,7 +1048,10 @@ export function ProductInnovationPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-white font-light">سایر</span> <span className="text-sm text-white font-light">سایر</span>
<Checkbox <Checkbox
checked={selectedProjectDetails?.developed_technology_type === "سایر"} checked={
selectedProjectDetails?.developed_technology_type ===
"سایر"
}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600" className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/> />
</div> </div>
@ -976,15 +1064,20 @@ export function ProductInnovationPage() {
استانداردهای ملی و بینالمللی اخذ شده استانداردهای ملی و بینالمللی اخذ شده
</h3> </h3>
{selectedProjectDetails?.obtained_standard_title && selectedProjectDetails?.obtained_standard_title.length > 0 ? ( {selectedProjectDetails?.obtained_standard_title &&
selectedProjectDetails?.obtained_standard_title.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{(Array.isArray(selectedProjectDetails?.obtained_standard_title) {(Array.isArray(
selectedProjectDetails?.obtained_standard_title
)
? selectedProjectDetails?.obtained_standard_title ? selectedProjectDetails?.obtained_standard_title
: [selectedProjectDetails?.obtained_standard_title] : [selectedProjectDetails?.obtained_standard_title]
).map((standard, index) => ( ).map((standard, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div> <div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
<span className="text-sm text-white font-light">{standard}</span> <span className="text-sm text-white font-light">
{standard}
</span>
</div> </div>
))} ))}
</div> </div>
@ -997,7 +1090,8 @@ export function ProductInnovationPage() {
{/* Knowledge-based Certificate Button */} {/* Knowledge-based Certificate Button */}
<div className="justify-self-centerr grid py-1 mx-auto"> <div className="justify-self-centerr grid py-1 mx-auto">
{selectedProjectDetails?.knowledge_based_certificate_obtained === "خیر" ? ( {selectedProjectDetails?.knowledge_based_certificate_obtained ===
"خیر" ? (
<div className=" border border-pr-red mx-auto rounded-lg p-2 text-center"> <div className=" border border-pr-red mx-auto rounded-lg p-2 text-center">
<button className="text-pr-red font-bold text-sm"> <button className="text-pr-red font-bold text-sm">
گواهی دانشبنیان ندارد گواهی دانشبنیان ندارد
@ -1028,10 +1122,14 @@ export function ProductInnovationPage() {
</p> </p>
<p className="text-sm text-white"> <p className="text-sm text-white">
<span className="font-bold">تاریخ اخذ: </span> <span className="font-bold">تاریخ اخذ: </span>
{handleDataValue(selectedProjectDetails?.certificate_obtain_date) || "—"} {handleDataValue(
selectedProjectDetails?.certificate_obtain_date
) || "—"}
</p> </p>
<p className="text-sm text-white"> <p className="text-sm text-white">
<span className="font-bold">مرجع صادرکننده: </span> <span className="font-bold">
مرجع صادرکننده:{" "}
</span>
{selectedProjectDetails?.issuing_authority || "—"} {selectedProjectDetails?.issuing_authority || "—"}
</p> </p>
</div> </div>
@ -1073,16 +1171,32 @@ export function ProductInnovationPage() {
<div className="rounded-lg pt-4 grid grid-cols-2 gap-4 w-full"> <div className="rounded-lg pt-4 grid grid-cols-2 gap-4 w-full">
<MetricCard <MetricCard
title="میزان صادارت محصول جدید" title="میزان صادارت محصول جدید"
value={Math.round(popupStats?.new_products_export > 0 ? popupStats?.new_products_export : 0)} value={Math.round(
percentValue={Math.round(popupStats?.new_products_export_percent > 0 ? popupStats?.new_products_export_percent : 0)} popupStats?.new_products_export > 0
? popupStats?.new_products_export
: 0
)}
percentValue={Math.round(
popupStats?.new_products_export_percent > 0
? popupStats?.new_products_export_percent
: 0
)}
valueLabel="میلیون ریال" valueLabel="میلیون ریال"
percentLabel="درصد به کل صادرات" percentLabel="درصد به کل صادرات"
/> />
<MetricCard <MetricCard
title="تاثیر در واردات" title="تاثیر در واردات"
value={Math.round(popupStats?.import_impact > 0 ? popupStats?.import_impact : 0)} value={Math.round(
percentValue={Math.round(popupStats?.import_impact_percent > 0 ? popupStats?.import_impact_percent : 0)} popupStats?.import_impact > 0
? popupStats?.import_impact
: 0
)}
percentValue={Math.round(
popupStats?.import_impact_percent > 0
? popupStats?.import_impact_percent
: 0
)}
valueLabel="میلیون ریال" valueLabel="میلیون ریال"
percentLabel="درصد صرفه جویی" percentLabel="درصد صرفه جویی"
/> />
@ -1090,7 +1204,9 @@ export function ProductInnovationPage() {
{/* Export Revenue Bar Chart */} {/* Export Revenue Bar Chart */}
<div className="bg-[linear-gradient(to_bottom_left,#464861,45%,#111628)] rounded-lg px-6 py-4"> <div className="bg-[linear-gradient(to_bottom_left,#464861,45%,#111628)] rounded-lg px-6 py-4">
<h3 className="text-sm font-semibold text-white">ظرفیت صادر شده</h3> <h3 className="text-sm font-semibold text-white">
ظرفیت صادر شده
</h3>
<div className="h-60"> <div className="h-60">
{exportChartData.length > 0 ? ( {exportChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@ -1108,57 +1224,152 @@ export function ProductInnovationPage() {
axisLine={false} axisLine={false}
stroke="#C3C3C3" stroke="#C3C3C3"
tickMargin={8} tickMargin={8}
tickFormatter={(value: string) => `${value.split(" ")[0]} ${formatNumber(value.split(" ")[1]).replaceAll('٬','')}`} tickFormatter={(value: string) =>
`${value.split(" ")[0]} ${formatNumber(value.split(" ")[1]).replaceAll("٬", "")}`
}
fontSize={11} fontSize={11}
/> />
<YAxis tickLine={false} axisLine={false} stroke="#9CA3AF" fontSize={11} tick={{ dx: -50 }} tickFormatter={(value: number) => `${formatNumber(value)} میلیون`} /> <YAxis
tickLine={false}
axisLine={false}
stroke="#9CA3AF"
fontSize={11}
tick={{ dx: -50 }}
tickFormatter={(value: number) =>
`${formatNumber(value)} میلیون`
}
/>
<Bar dataKey="value" fill="#10B981" radius={10}> <Bar dataKey="value" fill="#10B981" radius={10}>
<LabelList formatter={(value: number) => `${formatNumber(value)}`} position="top" offset={15} fill="F9FAFB" className="fill-foreground" fontSize={16} /> <LabelList
formatter={(value: number) =>
`${formatNumber(value)}`
}
position="top"
offset={15}
fill="F9FAFB"
className="fill-foreground"
fontSize={16}
/>
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex items-center justify-center h-full text-gray-400">دادهای برای نمایش وجود ندارد</div> <div className="flex items-center justify-center h-full text-gray-400">
دادهای برای نمایش وجود ندارد
</div>
)} )}
</div> </div>
</div> </div>
{/* Export Revenue Line Chart */} {/* Export Revenue Line Chart */}
<div className="bg-[linear-gradient(to_bottom_left,#464861,45%,#111628)] rounded-lg px-6 py-4"> <div className="bg-[linear-gradient(to_bottom_left,#464861,45%,#111628)] rounded-lg px-6 py-4">
<h3 className="text-sm font-semibold text-white">ظرفیت صادر شده</h3> <h3 className="text-sm font-semibold text-white">
ظرفیت صادر شده
</h3>
<div className="h-60"> <div className="h-60">
{allExportData.length > 0 ? ( {allExportData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart className="aspect-auto w-full" data={transformDataForLineChart(allExportData)} margin={{ top: 20, right: 30, left: 10, bottom: 50 }}> <LineChart
className="aspect-auto w-full"
data={transformDataForLineChart(allExportData)}
margin={{ top: 20, right: 30, left: 10, bottom: 50 }}
>
<CartesianGrid vertical={false} stroke="#374151" /> <CartesianGrid vertical={false} stroke="#374151" />
<XAxis dataKey="season" stroke="#9CA3AF" fontSize={11} tick={({ x, y, payload }) => ( <XAxis
dataKey="season"
stroke="#9CA3AF"
fontSize={11}
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y + 10})`}> <g transform={`translate(${x},${y + 10})`}>
<text x={-40} y={15} dy={0} textAnchor="end" fill="#9CA3AF" fontSize={11} transform="rotate(-45)">{(payload as any).value}</text> <text
x={-40}
y={15}
dy={0}
textAnchor="end"
fill="#9CA3AF"
fontSize={11}
transform="rotate(-45)"
>
{(payload as any).value}
</text>
</g> </g>
)} /> )}
<YAxis tickLine={false} axisLine={false} stroke="#9CA3AF" fontSize={11} tick={{ dx: -50 }} tickFormatter={(value) => `${formatNumber(value)} میلیون`} /> />
<Tooltip formatter={(value: number) => `${formatNumber(value)} میلیون`} contentStyle={{ backgroundColor: "#1F2937", border: "1px solid #374151", borderRadius: "6px", padding: "6px 10px", fontSize: "11px", color: "#F9FAFB" }} /> <YAxis
<Legend layout="vertical" verticalAlign="middle" align="right" iconType={"plainline"} className="!flex" wrapperStyle={{ fontSize: 11, paddingLeft: 12, gap: 10 }} /> tickLine={false}
{[...new Set(allExportData.map((item) => item.product_title))].slice(0, 5).map((product, index) => { axisLine={false}
const colors = ["#10B981", "#EF4444", "#3B82F6", "#F59E0B", "#8B5CF6"]; stroke="#9CA3AF"
return <Line key={product} type="linear" dot={false} activeDot={{ r: 5 }} dataKey={product} stroke={colors[index % colors.length]} strokeWidth={2} />; fontSize={11}
tick={{ dx: -50 }}
tickFormatter={(value) =>
`${formatNumber(value)} میلیون`
}
/>
<Tooltip
formatter={(value: number) =>
`${formatNumber(value)} میلیون`
}
contentStyle={{
backgroundColor: "#1F2937",
border: "1px solid #374151",
borderRadius: "6px",
padding: "6px 10px",
fontSize: "11px",
color: "#F9FAFB",
}}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType={"plainline"}
className="!flex"
wrapperStyle={{
fontSize: 11,
paddingLeft: 12,
gap: 10,
}}
/>
{[
...new Set(
allExportData.map((item) => item.product_title)
),
]
.slice(0, 5)
.map((product, index) => {
const colors = [
"#10B981",
"#EF4444",
"#3B82F6",
"#F59E0B",
"#8B5CF6",
];
return (
<Line
key={product}
type="linear"
dot={false}
activeDot={{ r: 5 }}
dataKey={product}
stroke={colors[index % colors.length]}
strokeWidth={2}
/>
);
})} })}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex items-center justify-center h-full text-gray-400">دادهای برای نمایش وجود ندارد</div> <div className="flex items-center justify-center h-full text-gray-400">
دادهای برای نمایش وجود ندارد
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@ -1,6 +1,8 @@
import { saveAs } from "file-saver";
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react"; import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import XLSX from "xlsx-js-style";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { import {
@ -12,9 +14,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils"; import {
import { formatNumber } from "~/lib/utils"; EventBus,
formatCurrency,
formatNumber,
handleDataValue,
} from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { DashboardLayout } from "../layout"; import { DashboardLayout } from "../layout";
interface ProjectData { interface ProjectData {
@ -167,6 +175,14 @@ export function ProjectManagementPage() {
}); });
const observerRef = useRef<HTMLDivElement>(null); const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// const [date, setDate] = useState<CalendarDate>({
// start: `${jy}/01/01`,
// end: `${jy}/12/30`,
// });
const [date, setDate] = useStoredDate();
const fetchProjects = async (reset = false) => { const fetchProjects = async (reset = false) => {
// Prevent concurrent API calls // Prevent concurrent API calls
@ -198,7 +214,10 @@ export function ProjectManagementPage() {
OutputFields: outputFields, OutputFields: outputFields,
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
Sorts: sortField ? [[sortField, sortConfig.direction]] : [], Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
Conditions: [], Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
if (response.state === 0) { if (response.state === 0) {
@ -263,16 +282,29 @@ export function ProjectManagementPage() {
} }
}; };
useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) { if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
setCurrentPage((prev) => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [loadingMore, hasMore, loading]); }, [hasMore, loading, loadingMore]);
useEffect(() => { useEffect(() => {
if (date.end && date.start) {
fetchProjects(true); fetchProjects(true);
fetchTotalCount(); fetchTotalCount();
}, [sortConfig]); }
}, [sortConfig, date]);
useEffect(() => { useEffect(() => {
if (currentPage > 1) { if (currentPage > 1) {
@ -280,30 +312,44 @@ export function ProjectManagementPage() {
} }
}, [currentPage]); }, [currentPage]);
// Infinite scroll observer // Infinite scroll observer with debouncing
useEffect(() => { useEffect(() => {
const scrollContainer = document.querySelector(".overflow-auto"); const scrollContainer = scrollContainerRef.current;
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return; if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
return;
// Clear previous timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Debounce scroll events
scrollTimeoutRef.current = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Trigger load more when scrolled to 90% of the container // Trigger load more when scrolled to 95% of the container
if (scrollPercentage >= 0.9) { if (scrollPercentage >= 0.95) {
loadMore(); loadMore();
} }
}, 150);
}; };
if (scrollContainer) { if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll); scrollContainer.addEventListener("scroll", handleScroll, {
passive: true,
});
} }
return () => { return () => {
if (scrollContainer) { if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll); scrollContainer.removeEventListener("scroll", handleScroll);
} }
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
}; };
}, [loadMore, hasMore, loadingMore]); }, [loadMore, hasMore, loadingMore]);
@ -324,7 +370,10 @@ export function ProjectManagementPage() {
const response = await apiService.select({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: ["count(project_no)"], OutputFields: ["count(project_no)"],
Conditions: [], Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
if (response.state === 0) { if (response.state === 0) {
@ -345,14 +394,14 @@ export function ProjectManagementPage() {
} }
}; };
const handleRefresh = () => { // const handleRefresh = () => {
fetchingRef.current = false; // Reset fetching state on refresh // fetchingRef.current = false; // Reset fetching state on refresh
setCurrentPage(1); // setCurrentPage(1);
setProjects([]); // setProjects([]);
setHasMore(true); // setHasMore(true);
fetchProjects(true); // fetchProjects(true);
fetchTotalCount(); // fetchTotalCount();
}; // };
// ...existing code... // ...existing code...
@ -554,7 +603,10 @@ export function ProjectManagementPage() {
// Compute counts and totals for each category so footer segments can be proportional // Compute counts and totals for each category so footer segments can be proportional
const categoryStats = useMemo(() => { const categoryStats = useMemo(() => {
const stats: Record<string, { counts: Record<string, number>; total: number }> = {}; const stats: Record<
string,
{ counts: Record<string, number>; total: number }
> = {};
categoryDefs.forEach((cat) => { categoryDefs.forEach((cat) => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
let total = 0; let total = 0;
@ -613,7 +665,9 @@ export function ProjectManagementPage() {
.map((p) => calculateRemainingDays((p as any).end_date)) .map((p) => calculateRemainingDays((p as any).end_date))
.filter((v) => v !== null) as number[]; .filter((v) => v !== null) as number[];
res["remaining_time"] = remainingValues.length res["remaining_time"] = remainingValues.length
? Math.round(remainingValues.reduce((a, b) => a + b, 0) / remainingValues.length) ? Math.round(
remainingValues.reduce((a, b) => a + b, 0) / remainingValues.length
)
: null; : null;
// For other keys, parse numeric values // For other keys, parse numeric values
@ -623,11 +677,17 @@ export function ProjectManagementPage() {
.map((p) => { .map((p) => {
const raw = (p as any)[k]; const raw = (p as any)[k];
if (raw == null) return NaN; if (raw == null) return NaN;
const num = Number(String(raw).toString().replace(/[^0-9.-]/g, "")); const num = Number(
String(raw)
.toString()
.replace(/[^0-9.-]/g, "")
);
return Number.isFinite(num) ? num : NaN; return Number.isFinite(num) ? num : NaN;
}) })
.filter((n) => !Number.isNaN(n)); .filter((n) => !Number.isNaN(n));
res[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; res[k] = vals.length
? vals.reduce((a, b) => a + b, 0) / vals.length
: null;
}); });
return res; return res;
@ -668,7 +728,9 @@ export function ProjectManagementPage() {
const color = getCategoryColor(column.key, value); const color = getCategoryColor(column.key, value);
return ( return (
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full"> <span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
<span className="text-gray-300">{!!value ? String(value) : "-"}</span> <span className="text-gray-300">
{!!value ? String(value) : "-"}
</span>
<span <span
style={{ style={{
backgroundColor: color, backgroundColor: color,
@ -689,25 +751,30 @@ export function ProjectManagementPage() {
case "deviation_from_program": case "deviation_from_program":
case "cost_deviation": case "cost_deviation":
return ( return (
<span className="text-sm font-normal">{formatNumber(value as any)}</span> <span className="text-sm font-normal">
{formatNumber(value as any)}
</span>
); );
case "start_date": case "start_date":
case "end_date": case "end_date":
case "done_date": case "done_date":
return ( return (
<span className=" text-sm font-normal">{formatDate(String(value))}</span> <span className=" text-sm font-normal">
{formatDate(String(value))}
</span>
); );
case "project_no": case "project_no":
return ( return (
<Badge <Badge variant="teal" className="border-emerald-500/50">
variant="teal"
className="border-emerald-500/50"
>
{String(value)} {String(value)}
</Badge> </Badge>
); );
case "title": case "title":
return <span className="text-sm font-normal text-white">{String(value)}</span>; return (
<span className="text-sm font-normal text-white">
{String(value)}
</span>
);
case "importance_project": case "importance_project":
return ( return (
<Badge <Badge
@ -730,16 +797,94 @@ export function ProjectManagementPage() {
} }
}; };
const totalPages = Math.ceil(totalCount / pageSize); // const totalPages = Math.ceil(totalCount / pageSize);
const exportToExcel = async () => {
let arr = [];
const data = await fetchExcelData();
debugger;
for (let i = 0; i < data.length; i++) {
let obj: Record<string, any> = {};
const project = data[i];
Object.entries(project).forEach(([pKey, pValue]) => {
Object.values(columns).forEach((col) => {
if (pKey === col.key) {
``;
obj[col.label] = handleDataValue(
pValue?.includes(",") ? pValue.replaceAll(",", "") : pValue
);
}
});
});
arr.push(obj);
}
// تبدیل داده‌ها به worksheet
const worksheet = XLSX.utils.json_to_sheet(arr);
// ساخت workbook
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "People");
// تبدیل به فایل باینری
const excelBuffer = XLSX.write(workbook, {
bookType: "xlsx",
type: "array",
});
const blob = new Blob([excelBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
saveAs(blob, "people.xls");
};
const fetchExcelData = async () => {
const fetchableColumns = columns.filter((c) => !c.computed);
const outputFields = fetchableColumns.map((c) => c.apiField ?? c.key);
const sortCol = columns.find((c) => c.key === sortConfig.field);
const sortField = sortCol?.computed
? undefined
: (sortCol?.apiField ?? sortCol?.key);
const response = await apiService.select({
ProcessName: "project",
OutputFields: outputFields,
Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
});
const parsedData = JSON.parse(response.data);
return parsedData;
};
return ( return (
<DashboardLayout title="مدیریت پروژه‌ها"> <DashboardLayout title="مدیریت پروژه‌ها">
<div className="p-6 space-y-6"> <div className="space-y-6">
{/* <div className="flex justify-end w-full mb-0 pl-2">
<Button
className="flex w-max justify-center rounded-xl mb-4 border-gray-500/20 border-2 cursor-pointer transition-all hover:bg-[#3F415A]/50 bg-[#3F415A] py-3 text-center items-center gap-3 "
variant="ghost"
size="sm"
onClick={exportToExcel}
>
<FileChartColumnIncreasing />
دانلود فایل اکسل
</Button>
</div> */}
{/* Data Table */} {/* Data Table */}
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden"> <Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
{/* <div onClick={exportToExcel}>DownloadExcle</div> */}
<CardContent className="p-0"> <CardContent className="p-0">
<div className="relative"> <div className="relative">
<div className="relative overflow-auto custom-scrollbar max-h-[calc(100vh-120px)]"> <div
ref={scrollContainerRef}
className="relative overflow-auto custom-scrollbar max-h-[calc(100vh-120px)]"
>
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]"> <TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
<TableRow className="bg-[#3F415A]"> <TableRow className="bg-[#3F415A]">
@ -767,8 +912,7 @@ export function ProjectManagementPage() {
</button> </button>
) : ( ) : (
column.label column.label
) )}
}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@ -791,7 +935,9 @@ export function ProjectManagementPage() {
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" /> <div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
<div <div
className="h-2.5 bg-gray-600 rounded animate-pulse" className="h-2.5 bg-gray-600 rounded animate-pulse"
style={{ width: `${Math.random() * 60 + 40}%` }} style={{
width: `${Math.random() * 60 + 40}%`,
}}
/> />
</div> </div>
</TableCell> </TableCell>
@ -834,7 +980,10 @@ export function ProjectManagementPage() {
// First column: show total projects text similar to API count // First column: show total projects text similar to API count
if (colIndex === 0) { if (colIndex === 0) {
return ( return (
<TableCell key={column.key} className="p-3 text-sm text-white font-semibold font-persian"> <TableCell
key={column.key}
className="p-3 text-sm text-white font-semibold font-persian"
>
کل پروژهها: {formatNumber(actualTotalCount)} کل پروژهها: {formatNumber(actualTotalCount)}
</TableCell> </TableCell>
); );
@ -860,15 +1009,18 @@ export function ProjectManagementPage() {
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex"> <div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
{order.map((k) => { {order.map((k) => {
const cnt = imp.counts[k] || 0; const cnt = imp.counts[k] || 0;
const widthPercent = imp.total > 0 ? (cnt / imp.total) * 100 : 0; const widthPercent =
imp.total > 0 ? (cnt / imp.total) * 100 : 0;
return ( return (
<div <div
key={k} key={k}
title={`${k} (${cnt})`} title={`${k} (${cnt})`}
className="h-3 flex items-center justify-center text-xs font-medium" className="h-3 flex items-center justify-center text-xs font-medium"
style={{ width: `${widthPercent}%`, backgroundColor: colorFor(k) }} style={{
> width: `${widthPercent}%`,
</div> backgroundColor: colorFor(k),
}}
></div>
); );
})} })}
</div> </div>
@ -884,26 +1036,37 @@ export function ProjectManagementPage() {
"executive_phase", "executive_phase",
]; ];
if (categoryLike.includes(column.key)) { if (categoryLike.includes(column.key)) {
const stat = categoryStats[column.key] || { counts: {}, total: 0 }; const stat = categoryStats[column.key] || {
counts: {},
total: 0,
};
const entries = Object.entries(stat.counts); const entries = Object.entries(stat.counts);
return ( return (
<TableCell key={column.key} className="p-1"> <TableCell key={column.key} className="p-1">
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex"> <div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
{entries.length > 0 ? ( {entries.length > 0 ? (
entries.map(([val, cnt]) => { entries.map(([val, cnt]) => {
let color = categoryColorMaps[column.key]?.[val] || "#6B7280"; let color =
categoryColorMaps[column.key]?.[val] ||
"#6B7280";
if (column.key === "executive_phase") { if (column.key === "executive_phase") {
color = (phaseColors as any)[val] || color; color =
(phaseColors as any)[val] || color;
} }
const widthPercent = stat.total > 0 ? (cnt / stat.total) * 100 : 0; const widthPercent =
stat.total > 0
? (cnt / stat.total) * 100
: 0;
return ( return (
<div <div
key={val} key={val}
title={`${val} (${cnt})`} title={`${val} (${cnt})`}
className="h-3 flex items-center justify-center text-xs font-medium" className="h-3 flex items-center justify-center text-xs font-medium"
style={{ width: `${widthPercent}%`, backgroundColor: color }} style={{
> width: `${widthPercent}%`,
</div> backgroundColor: color,
}}
></div>
); );
}) })
) : ( ) : (
@ -921,10 +1084,23 @@ export function ProjectManagementPage() {
// remaining_time: show average days with color (green/red/white) // remaining_time: show average days with color (green/red/white)
if (column.key === "remaining_time") { if (column.key === "remaining_time") {
const avg = numericAverages["remaining_time"] as number | null; const avg = numericAverages["remaining_time"] as
const color = avg == null ? "#9CA3AF" : avg > 0 ? "#3AEA83" : avg < 0 ? "#F76276" : "#FFFFFF"; | number
| null;
const color =
avg == null
? "#9CA3AF"
: avg > 0
? "#3AEA83"
: avg < 0
? "#F76276"
: "#FFFFFF";
return ( return (
<TableCell key={column.key} className="p-2 text-right font-medium" style={{ color }}> <TableCell
key={column.key}
className="p-2 text-right font-medium"
style={{ color }}
>
{avg == null ? "-" : `${formatNumber(avg)} روز`} {avg == null ? "-" : `${formatNumber(avg)} روز`}
</TableCell> </TableCell>
); );
@ -943,10 +1119,15 @@ export function ProjectManagementPage() {
const avg = numericAverages[mapped] as number | null; const avg = numericAverages[mapped] as number | null;
let display = "-"; let display = "-";
if (avg != null) { if (avg != null) {
display = mapped.includes("budget") ? formatCurrency(String(Math.round(avg))) : formatNumber(Math.round(avg)); display = mapped.includes("budget")
? formatCurrency(String(Math.round(avg)))
: formatNumber(Math.round(avg));
} }
return ( return (
<TableCell key={column.key} className="p-2 text-right font-medium text-gray-200"> <TableCell
key={column.key}
className="p-2 text-right font-medium text-gray-200"
>
{display} {display}
</TableCell> </TableCell>
); );
@ -973,8 +1154,6 @@ export function ProjectManagementPage() {
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@ -1,25 +1,16 @@
import { import {
Box,
Building2,
ChevronDown, ChevronDown,
ChevronRight,
FolderKanban,
GalleryVerticalEnd, GalleryVerticalEnd,
Globe, House,
LayoutDashboard, LightbulbIcon,
Leaf, ListTodo,
Lightbulb,
LogOut, LogOut,
MonitorSmartphone, Radar,
Package,
Settings, Settings,
Star, Star,
Workflow, Workflow,
DiscAlbum, DiscAlbum,
House, LucideLightbulb
ListTodo,
LightbulbIcon,
Radar
} from "lucide-react"; } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
@ -49,7 +40,6 @@ interface MenuItem {
} }
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
id: "dashboard", id: "dashboard",
label: "صفحه اصلی", label: "صفحه اصلی",
@ -108,31 +98,24 @@ const menuItems: MenuItem[] = [
{ {
id: "ideas", id: "ideas",
label: "ایده‌های فناوری و نوآوری", label: "ایده‌های فناوری و نوآوری",
icon: House, icon: LucideLightbulb,
href: "/dashboard/manage-ideas-tech", href: "/dashboard/manage-ideas-tech",
}, },
{
id: "top-innovations",
label: "نوآور برتر",
icon: Star,
href: "/dashboard/top-innovations",
},
{ {
id: "strategic-alignment", id: "strategic-alignment",
label: "میزان انطباق راهبردی", label: "میزان انطباق راهبردی",
icon: null, icon: null,
href: "#", // This is not a route, it opens a popup href: "#", // This is not a route, it opens a popup
}, },
]; ];
const bottomMenuItems: MenuItem[] = [ const bottomMenuItems: MenuItem[] = [
{ // {
id: "settings", // id: "settings",
label: "تنظیمات", // label: "تنظیمات",
icon: Settings, // icon: Settings,
href: "/dashboard/settings", // href: "/dashboard/settings",
}, // },
{ {
id: "logout", id: "logout",
label: "خروج", label: "خروج",
@ -172,7 +155,10 @@ export function Sidebar({
// Update header title based on current route // Update header title based on current route
// If a child route is active, use that child's label prefixed by parent label // If a child route is active, use that child's label prefixed by parent label
let activeTitle: string | undefined = undefined; let activeTitle: string | undefined = undefined;
let activeIcon: React.ComponentType<{ className?: string }> | null | undefined = undefined; let activeIcon:
| React.ComponentType<{ className?: string }>
| null
| undefined = undefined;
menuItems.forEach((item) => { menuItems.forEach((item) => {
if (item.children) { if (item.children) {
const activeChild = item.children.find( const activeChild = item.children.find(
@ -190,7 +176,10 @@ export function Sidebar({
} }
}); });
if (onTitleChange) { if (onTitleChange) {
onTitleChange({ title: activeTitle ?? "صفحه اول", icon: activeIcon ?? null }); onTitleChange({
title: activeTitle ?? "صفحه اول",
icon: activeIcon ?? null,
});
} }
}; };
@ -261,7 +250,7 @@ export function Sidebar({
<button <button
key={item.id} key={item.id}
className={cn( className={cn(
"flex items-center justify-center w-full px-2 rounded-none mt-4 transition-all duration-200 group", "flex items-center justify-center w-full px-2 rounded-none mt-4 transition-all duration-200 group"
)} )}
onClick={handleClick} onClick={handleClick}
> >
@ -271,8 +260,7 @@ export function Sidebar({
</span> </span>
</div> </div>
</button> </button>
) );
} }
return ( return (
@ -435,9 +423,9 @@ export function Sidebar({
/> />
<div className="font-persian"> <div className="font-persian">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
اینوژن بندر امام داشبورد مدیریت فناوری و نوآوری
</div> </div>
<div className="text-xs text-gray-400">نسخه ۰.۱</div> {/* <div className="text-xs text-gray-400">نسخه ۰.۱</div> */}
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,33 +1,80 @@
import React, { useEffect, useState } from "react"; import { useEffect, useReducer, useRef, useState } from "react";
import { import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
BarChart,
Bar, Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LabelList,
Cell,
} from "recharts"; } from "recharts";
import apiService from "~/lib/api"; import { Dialog, DialogContent, DialogHeader } from "~/components/ui/dialog";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { formatNumber } from "~/lib/utils"; import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api";
import { EventBus, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { ChartContainer } from "../ui/chart"; import { ChartContainer } from "../ui/chart";
import {
DropdownMenu,
DropdownMenuButton,
DropdownMenuContent,
DropdownMenuItem,
} from "../ui/dropdown-menu";
import { TruncatedText } from "../ui/truncatedText"; import { TruncatedText } from "../ui/truncatedText";
interface StrategicAlignmentData { interface StrategicAlignmentData {
strategic_theme: string; strategic_theme: string;
operational_fee_sum: number; operational_fee_count: number;
percentage?: number; percentage?: number;
} }
interface DropDownConfig {
isOpen: boolean;
selectedValue: string;
dropDownItems: Array<string>;
}
type Action =
| { type: "OPEN" }
| { type: "CLOSE" }
| { type: "SETVALUE"; value: Array<string> }
| { type: "SELECT"; value: string };
// const DropDownItems = [
// {
// id: 0,
// key: "همه مضامین",
// Value: "همه مضامین",
// },
// {
// id: 1,
// key: "ارزش های هم افزایی نوآورانه",
// Value: "همه مضامین",
// },
// {
// id: 2,
// key: "ارزش های خودکفایی نوآوورانه",
// Value: "همه مضامین",
// },
// {
// id: 3,
// key: "ارزش های فناوری های نوین",
// Value: "همه مضامین",
// },
// {
// id: 4,
// key: "ارزش های توسعه منابع انسانی",
// Value: "همه مضامین",
// },
// {
// id: 5,
// key: "ارزش های نوآوری سبز",
// Value: "همه مضامین",
// },
// ];
interface StrategicAlignmentPopupProps { interface StrategicAlignmentPopupProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -45,7 +92,6 @@ const chartConfig = {
const barHeights = () => Math.floor(Math.random() * maxHeight); const barHeights = () => Math.floor(Math.random() * maxHeight);
const ChartSkeleton = () => ( const ChartSkeleton = () => (
<div className="flex justify-center h-96 w-full p-4"> <div className="flex justify-center h-96 w-full p-4">
{/* Chart bars */} {/* Chart bars */}
<div className=" w-full flex items-end gap-10"> <div className=" w-full flex items-end gap-10">
@ -74,6 +120,14 @@ export function StrategicAlignmentPopup({
}: StrategicAlignmentPopupProps) { }: StrategicAlignmentPopupProps) {
const [data, setData] = useState<StrategicAlignmentData[]>([]); const [data, setData] = useState<StrategicAlignmentData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const [state, dispatch] = useReducer(reducer, {
isOpen: false,
selectedValue: "همه مضامین",
dropDownItems: [],
});
const [date, setDate] = useStoredDate();
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -81,16 +135,29 @@ export function StrategicAlignmentPopup({
} }
}, [open]); }, [open]);
useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiService.select({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: [ OutputFields: ["strategic_theme", "count(operational_fee)"],
"strategic_theme",
"sum(operational_fee) as operational_fee_sum",
],
GroupBy: ["strategic_theme"], GroupBy: ["strategic_theme"],
Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
}); });
const responseData = const responseData =
@ -98,16 +165,102 @@ export function StrategicAlignmentPopup({
? JSON.parse(response.data) ? JSON.parse(response.data)
: response.data; : response.data;
setBarItems(responseData);
const dropDownItems = responseData.map(
(item: any) => item.strategic_theme
);
setDropDownValues(["همه مضامین", ...dropDownItems]);
} catch (error) {
console.error("Error fetching strategic alignment data:", error);
} finally {
setLoading(false);
}
};
const fetchDropDownItems = async (item: string) => {
try {
if (item !== "همه مضامین") {
const response = await apiService.select({
ProcessName: "project",
OutputFields: [
"value_technology_and_innovation",
"count(operational_fee)",
],
Conditions: [
["strategic_theme", "=", item, "and"],
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
GroupBy: ["value_technology_and_innovation"],
});
const responseData =
typeof response.data === "string"
? JSON.parse(response.data)
: response.data;
setBarItems(responseData);
} else fetchData();
} catch (error) {
console.error("Error fetching strategic alignment data:", error);
} finally {
setLoading(false);
}
};
function reducer(state: DropDownConfig, action: Action): DropDownConfig {
switch (action.type) {
case "OPEN":
return { ...state, isOpen: true };
case "CLOSE":
return { ...state, isOpen: false };
case "SETVALUE":
return { ...state, dropDownItems: action.value };
case "SELECT":
return { ...state, selectedValue: action.value };
default:
return state;
}
}
const toggleMenuHandler = () => {
dispatch({
type: "OPEN",
});
};
const selectItem = (item: string) => {
dispatch({
type: "SELECT",
value: item,
});
dispatch({
type: "CLOSE",
});
fetchDropDownItems(item);
};
const setDropDownValues = (items: Array<string>) => {
dispatch({
type: "SETVALUE",
value: items,
});
};
const setBarItems = (responseData: any) => {
const processedData = responseData const processedData = responseData
.map((item: any) => ({ .map((item: any) => ({
strategic_theme: item.strategic_theme || "N/A", strategic_theme:
operational_fee_sum: Math.max(0, Number(item.operational_fee_sum)), item.strategic_theme || item.value_technology_and_innovation || "N/A",
operational_fee_count: Math.max(0, Number(item.operational_fee_count)),
})) }))
.filter((item: StrategicAlignmentData) => item.strategic_theme !== ""); .filter((item: StrategicAlignmentData) => item.strategic_theme !== "");
const total = processedData.reduce( const total = processedData.reduce(
(acc: number, item: StrategicAlignmentData) => (acc: number, item: StrategicAlignmentData) =>
acc + item.operational_fee_sum, acc + item.operational_fee_count,
0 0
); );
@ -116,23 +269,72 @@ export function StrategicAlignmentPopup({
...item, ...item,
percentage: percentage:
total > 0 total > 0
? Math.round((item.operational_fee_sum / total) * 100) ? Math.round((item.operational_fee_count / total) * 100)
: 0, : 0,
}) })
); );
setData(dataWithPercentage || []); setData(dataWithPercentage || []);
} catch (error) { };
console.error("Error fetching strategic alignment data:", error);
} finally { const dialogHandler = (status: boolean) => {
setLoading(false); if (onOpenChange) onOpenChange(status);
dispatch({
type: "SELECT",
value: "همه مضامین",
});
};
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(event.target as Node)
) {
dispatch({
type: "CLOSE",
});
} }
}; };
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [open]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={dialogHandler}>
<DialogContent className="w-full max-w-4xl bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] text-white border-none"> <DialogContent className="w-full max-w-4xl bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] text-white border-none">
<DialogHeader className="border-b-3 mb-10 py-2 w-full pb-4 border-b-2 border-gray-500/20"> <DialogHeader className="mb-10 w-full border-b-2 border-gray-500/20">
<DialogTitle className="ml-auto text-sm text-white ">میزان انطباق راهبردی</DialogTitle> <div>
<div className="flex">
<DropdownMenu
modal={true}
open={state.isOpen}
onOpenChange={toggleMenuHandler}
>
<DropdownMenuButton>{state.selectedValue}</DropdownMenuButton>
<DropdownMenuContent
ref={contentRef}
forceMount={true}
className="w-56"
>
{state.dropDownItems.map((item: string, key: number) => (
<div
onClick={() => selectItem(item)}
key={`${key}-${item}`}
>
<DropdownMenuItem selected={state.selectedValue === item}>
{item}
</DropdownMenuItem>
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
@ -140,7 +342,10 @@ export function StrategicAlignmentPopup({
) : ( ) : (
<> <>
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<ChartContainer config={chartConfig} className="aspect-auto h-96 w-full"> <ChartContainer
config={chartConfig}
className="aspect-auto h-96 w-full"
>
<BarChart <BarChart
data={data} data={data}
margin={{ left: 12, right: 12 }} margin={{ left: 12, right: 12 }}
@ -161,10 +366,7 @@ export function StrategicAlignmentPopup({
return ( return (
<g transform={`translate(${x},${y})`}> <g transform={`translate(${x},${y})`}>
<foreignObject width={80} height={20} x={-45} y={0}> <foreignObject width={80} height={20} x={-45} y={0}>
<TruncatedText <TruncatedText maxWords={2} text={payload.value} />
maxWords={2}
text={payload.value}
/>
</foreignObject> </foreignObject>
</g> </g>
); );
@ -179,8 +381,6 @@ export function StrategicAlignmentPopup({
tickFormatter={(value) => tickFormatter={(value) =>
`${formatNumber(Math.round(value))}` `${formatNumber(Math.round(value))}`
} }
label={{ label={{
value: "تعداد برنامه ها", value: "تعداد برنامه ها",
angle: -90, angle: -90,
@ -195,21 +395,24 @@ export function StrategicAlignmentPopup({
<Bar dataKey="percentage" radius={[8, 8, 0, 0]}> <Bar dataKey="percentage" radius={[8, 8, 0, 0]}>
{data.map((entry, index) => ( {data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={chartConfig.percentage.color} /> <Cell
key={`cell-${index}`}
fill={chartConfig.percentage.color}
/>
))} ))}
<LabelList <LabelList
dataKey="percentage" dataKey="percentage"
position="top" position="top"
offset={15} offset={15}
style={{ style={{
fill: "#ffffff", fill: "#ffffff",
fontSize: "16px", fontSize: "16px",
fontWeight: "bold", fontWeight: "bold",
}} }}
formatter={(v: number) => `${formatNumber(Math.round(v))}`} formatter={(v: number) =>
`${formatNumber(Math.round(v))}`
}
/> />
</Bar> </Bar>
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { import {
Area, Area,
AreaChart, AreaChart,
@ -11,9 +10,12 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { CustomBarChart } from "~/components/ui/custom-bar-chart"; import { CustomBarChart } from "~/components/ui/custom-bar-chart";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api"; import apiService from "~/lib/api";
import { formatNumber } from "~/lib/utils"; import { EventBus, formatNumber } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
export interface CompanyDetails { export interface CompanyDetails {
id: string; id: string;
@ -62,27 +64,50 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
const [counts, setCounts] = useState<EcosystemCounts | null>(null); const [counts, setCounts] = useState<EcosystemCounts | null>(null);
const [processData, setProcessData] = useState<ProcessActorsData[]>([]); const [processData, setProcessData] = useState<ProcessActorsData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// const [date, setDate] = useState<CalendarDate>();
const [date, setDate] = useStoredDate();
useEffect(() => { useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => {
if (date.end && date.start) fetchCounts();
}, [date]);
const fetchCounts = async () => { const fetchCounts = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const [countsRes, processRes] = await Promise.all([ const [countsRes, processRes] = await Promise.all([
apiService.call<EcosystemCounts>({ apiService.call<EcosystemCounts>({
ecosystem_count_function: {}, ecosystem_count_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}), }),
apiService.call<ProcessActorsResponse[]>({ apiService.call<ProcessActorsResponse[]>({
process_creating_actors_function: {}, process_creating_actors_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}), }),
]); ]);
setCounts( setCounts(
JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0], JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0]
); );
// Process the years data and fill missing years // Process the years data and fill missing years
const processedData = processYearsData( const processedData = processYearsData(
JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors), JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors)
); );
setProcessData(processedData); setProcessData(processedData);
} catch (err) { } catch (err) {
@ -91,8 +116,6 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchCounts();
}, []);
// Helper function to safely parse numbers // Helper function to safely parse numbers
const parseNumber = (value: string | undefined): number => { const parseNumber = (value: string | undefined): number => {
@ -103,7 +126,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
// Helper function to process years data and fill missing years // Helper function to process years data and fill missing years
const processYearsData = ( const processYearsData = (
data: ProcessActorsResponse[], data: ProcessActorsResponse[]
): ProcessActorsData[] => { ): ProcessActorsData[] => {
if (!data || data.length === 0) return []; if (!data || data.length === 0) return [];
@ -121,7 +144,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
acc[item.start_year] = item.total_count; acc[item.start_year] = item.total_count;
return acc; return acc;
}, },
{} as Record<string, number>, {} as Record<string, number>
); );
for (let year = minYear; year <= maxYear; year++) { for (let year = minYear; year <= maxYear; year++) {
@ -167,7 +190,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{ label: "شتابدهنده", value: parseNumber(counts.accelerator_count) }, { label: "شتابدهنده", value: parseNumber(counts.accelerator_count) },
{ label: "دانشگاه", value: parseNumber(counts.university_count) }, { label: "دانشگاه", value: parseNumber(counts.university_count) },
{ label: "صندوق های مالی", value: parseNumber(counts.fund_count) }, { label: "صندوق های مالی", value: parseNumber(counts.fund_count) },
{ label: "شرکت", value: parseNumber(counts.company_count) }, { label: "تامین کننده", value: parseNumber(counts.company_count) },
] ]
: []; : [];
@ -256,7 +279,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div
key={i} key={i}
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse" className="absolute w-2 h-2 bg-pr-green rounded-full animate-pulse"
style={{ style={{
left: `${20 + i * 25}%`, left: `${20 + i * 25}%`,
top: `${30 + Math.random() * 40}%`, top: `${30 + Math.random() * 40}%`,
@ -287,7 +310,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{/* Actor Count Skeleton */} {/* Actor Count Skeleton */}
<CardHeader className="text-center pt-0 pb-4"> <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-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> <div className="w-16 h-8 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
</CardHeader> </CardHeader>
{/* Bar Chart Skeleton */} {/* Bar Chart Skeleton */}
@ -362,7 +385,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div
key={i} key={i}
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse" className="absolute w-2 h-2 bg-pr-green rounded-full animate-pulse"
style={{ style={{
left: `${20 + i * 25}%`, left: `${20 + i * 25}%`,
top: `${30 + Math.random() * 40}%`, top: `${30 + Math.random() * 40}%`,
@ -378,7 +401,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
<CardContent className="pt-0 pb-6"> <CardContent className="pt-0 pb-6">
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center"> <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-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 className="w-12 h-6 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -432,6 +455,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
<CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]"> <CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]">
<div className="w-full"> <div className="w-full">
<CustomBarChart <CustomBarChart
hasPercent={false}
data={barData.map((item) => ({ data={barData.map((item) => ({
label: item.label, label: item.label,
value: item.value, value: item.value,
@ -461,7 +485,13 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
margin={{ top: 25, right: 30, left: 0, bottom: 0 }} margin={{ top: 25, right: 30, left: 0, bottom: 0 }}
> >
<defs> <defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1"> <linearGradient
id="fillDesktop"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="#3AEA83" stopOpacity={1} /> <stop offset="0%" stopColor="#3AEA83" stopOpacity={1} />
<stop offset="100%" stopColor="#3AEA83" stopOpacity={0} /> <stop offset="100%" stopColor="#3AEA83" stopOpacity={0} />
</linearGradient> </linearGradient>
@ -500,7 +530,14 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
activeDot={({ cx, cy, payload }) => ( activeDot={({ cx, cy, payload }) => (
<g> <g>
{/* Small circle */} {/* Small circle */}
<circle cx={cx} cy={cy} r={5} fill="#3AEA83" stroke="#fff" strokeWidth={2} /> <circle
cx={cx}
cy={cy}
r={5}
fill="#3AEA83"
stroke="#fff"
strokeWidth={2}
/>
{/* Year label above point */} {/* Year label above point */}
<text <text
x={cx} x={cx}
@ -517,7 +554,6 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex items-center justify-center h-full text-gray-400 font-persian"> <div className="flex items-center justify-center h-full text-gray-400 font-persian">
دادهای برای نمایش وجود ندارد دادهای برای نمایش وجود ندارد
@ -525,7 +561,6 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );

View File

@ -1,11 +1,19 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import * as d3 from "d3"; import * as d3 from "d3";
import apiService from "../../lib/api"; import { useCallback, useEffect, useRef, useState } from "react";
import { useStoredDate } from "~/hooks/useStoredDate";
import { EventBus } from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { useAuth } from "../../contexts/auth-context"; import { useAuth } from "../../contexts/auth-context";
import apiService from "../../lib/api";
// Get API base URL at module level to avoid process.env access in browser
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api"; //بندر امام
// import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
//آپادانا
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
//نوری
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
export interface Node { export interface Node {
id: string; id: string;
@ -44,9 +52,9 @@ export interface CompanyDetails {
export interface NetworkGraphProps { export interface NetworkGraphProps {
onNodeClick?: (node: CompanyDetails) => void; onNodeClick?: (node: CompanyDetails) => void;
onLoadingChange?: (loading: boolean) => void;
} }
// Helper to robustly parse backend response
function parseApiResponse(raw: any): any[] { function parseApiResponse(raw: any): any[] {
let data = raw; let data = raw;
try { try {
@ -56,12 +64,14 @@ function parseApiResponse(raw: any): any[] {
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
// Check if we're in browser environment
function isBrowser(): boolean { function isBrowser(): boolean {
return typeof window !== "undefined"; return typeof window !== "undefined";
} }
export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { export function NetworkGraph({
onNodeClick,
onLoadingChange,
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement | null>(null); const svgRef = useRef<SVGSVGElement | null>(null);
const [nodes, setNodes] = useState<Node[]>([]); const [nodes, setNodes] = useState<Node[]>([]);
const [links, setLinks] = useState<Link[]>([]); const [links, setLinks] = useState<Link[]>([]);
@ -70,7 +80,21 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { token } = useAuth(); const { token } = useAuth();
// Ensure component only renders on client side // const [date, setDate] = useState<CalendarDate>();
const [date, setDate] = useStoredDate();
useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => { useEffect(() => {
if (isBrowser()) { if (isBrowser()) {
const timer = setTimeout(() => setIsMounted(true), 100); const timer = setTimeout(() => setIsMounted(true), 100);
@ -78,7 +102,27 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
} }
}, []); }, []);
// Fetch data from API const getImageUrl = useCallback(
(stageid: number) => {
if (!token?.accessToken) return null;
return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`;
},
[token?.accessToken]
);
const callAPI = useCallback(
async (stage_id: number) => {
return await apiService.call<any>({
get_values_workflow_function: {
stage_id: stage_id,
// start_date: date?.start || null,
// end_date: date?.end || null,
},
});
},
[date]
);
useEffect(() => { useEffect(() => {
if (!isMounted) return; if (!isMounted) return;
@ -89,28 +133,45 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
setIsLoading(true); setIsLoading(true);
try { try {
const res = await apiService.call<any[]>({ const res = await apiService.call<any[]>({
graph_production_function: {}, graph_production_function: {
start_date: date.start || null,
end_date: date.end || null,
},
}); });
if (aborted) return; if (aborted) return;
const data = parseApiResponse(JSON.parse(res.data)?.graph_production); const data = parseApiResponse(JSON.parse(res.data)?.graph_production);
console.log( console.log(
"All available fields in first item:", "All available fields in first item:",
Object.keys(data[0] || {}), Object.keys(data[0] || {})
); );
// Create center node // نود مرکزی
const centerNode: Node = { const centerNode: Node = {
id: "center", id: "center",
label: "پتروشیمی بندر امام", //مرکز زیست بوم // label: "پتروشیمی بندر امام",
// label: "پتروشیمی نوری",
label: "پتروشیمی آپادانا",
category: "center", category: "center",
stageid: 0, stageid: 0,
isCenter: true, isCenter: true,
}; };
// Create ecosystem nodes // دسته‌بندی‌ها
const ecosystemNodes: Node[] = data.map((item: any) => ({ const categories = Array.from(
id: String(item.stageid), new Set(data.map((item: any) => item.category))
);
const categoryNodes: Node[] = categories.map((cat, index) => ({
id: `cat-${index}`,
label: cat,
category: cat,
stageid: -1,
}));
// نودهای نهایی
const finalNodes: Node[] = data.map((item: any) => ({
id: `node-${item.stageid}`,
label: item.title, label: item.title,
category: item.category, category: item.category,
stageid: item.stageid, stageid: item.stageid,
@ -118,13 +179,16 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
rawData: item, rawData: item,
})); }));
// Create links (all nodes connected to center) // لینک‌ها: مرکز → دسته‌بندی‌ها → نودهای نهایی
const graphLinks: Link[] = ecosystemNodes.map((node) => ({ const graphLinks: Link[] = [
source: "center", ...categoryNodes.map((cat) => ({ source: "center", target: cat.id })),
target: node.id, ...finalNodes.map((node) => {
})); const catIndex = categories.indexOf(node.category);
return { source: `cat-${catIndex}`, target: node.id };
}),
];
setNodes([centerNode, ...ecosystemNodes]); setNodes([centerNode, ...categoryNodes, ...finalNodes]);
setLinks(graphLinks); setLinks(graphLinks);
} catch (err: any) { } catch (err: any) {
if (err.name !== "AbortError") { if (err.name !== "AbortError") {
@ -142,43 +206,19 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
aborted = true; aborted = true;
controller.abort(); controller.abort();
}; };
}, [isMounted, token]); }, [isMounted, token, getImageUrl, date]);
// Get image URL for a node
const getImageUrl = useCallback(
(stageid: number) => {
if (!token?.accessToken) return null;
return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`;
},
[token?.accessToken],
);
// Import apiService for the onClick handler
const callAPI = useCallback(async (stage_id: number) => {
return await apiService.call<any>({
get_values_workflow_function: {
stage_id: stage_id,
},
});
}, []);
// Initialize D3 graph
useEffect(() => { useEffect(() => {
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0) { if (!isMounted || !svgRef.current || isLoading || nodes.length === 0)
return; return;
}
const svg = d3.select(svgRef.current); const svg = d3.select(svgRef.current);
const width = svgRef.current.clientWidth; const width = svgRef.current.clientWidth;
const height = svgRef.current.clientHeight; const height = svgRef.current.clientHeight;
// Clear previous content
svg.selectAll("*").remove(); svg.selectAll("*").remove();
// Create defs for patterns and filters
const defs = svg.append("defs"); const defs = svg.append("defs");
// Add glow filter for hover effect
const filter = defs const filter = defs
.append("filter") .append("filter")
.attr("id", "glow") .attr("id", "glow")
@ -196,33 +236,27 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
feMerge.append("feMergeNode").attr("in", "coloredBlur"); feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic"); feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Create zoom behavior const container = svg.append("g");
const zoom = d3 const zoom = d3
.zoom<SVGSVGElement, unknown>() .zoom<SVGSVGElement, unknown>()
.scaleExtent([0.8, 2.5]) // Limit zoom out to 1x, zoom in to 2.5x .scaleExtent([0.3, 2.5])
.on("zoom", (event) => { .on("zoom", (event) => container.attr("transform", event.transform));
container.attr("transform", event.transform);
});
svg.call(zoom); svg.call(zoom);
// Create container group
const container = svg.append("g");
// Category colors
const categoryToColor: Record<string, string> = { const categoryToColor: Record<string, string> = {
دانشگاه: "#3B82F6", دانشگاه: "#3B82F6",
مشاور: "#10B981", مشاور: "#10B981",
"دانش بنیان": "#F59E0B", "دانش بنیان": "#F59E0B",
استارتاپ: "#EF4444", استارتاپ: "#EF4444",
شرکت: "#8B5CF6", "تامین کننده": "#8B5CF6",
صندوق: "#06B6D4", صندوق: "#06B6D4",
شتابدهنده: "#9333EA", شتابدهنده: "#9333EA",
"مرکز نوآوری": "#F472B6", "مرکز نوآوری": "#F472B6",
center: "#34D399", center: "#34D399",
}; };
// Create force simulation
const simulation = d3 const simulation = d3
.forceSimulation<Node>(nodes) .forceSimulation<Node>(nodes)
.force( .force(
@ -231,16 +265,21 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.forceLink<Node, Link>(links) .forceLink<Node, Link>(links)
.id((d) => d.id) .id((d) => d.id)
.distance(150) .distance(150)
.strength(0.1), .strength(0.2)
) )
.force("charge", d3.forceManyBody().strength(-300)) .force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2)) .force("center", d3.forceCenter(width / 2, height / 2))
.force(
"radial",
d3.forceRadial((d) => (d.isCenter ? 0 : 300), width / 2, height / 2)
)
.force( .force(
"collision", "collision",
d3.forceCollide().radius((d) => (d.isCenter ? 40 : 30)), d3.forceCollide().radius((d) => (d.isCenter ? 50 : 35))
); );
const initialScale = 0.85; // Initial zoom to show entire graph
const initialScale = 0.6;
const initialTranslate = [ const initialTranslate = [
width / 2 - (width / 2) * initialScale, width / 2 - (width / 2) * initialScale,
height / 2 - (height / 2) * initialScale, height / 2 - (height / 2) * initialScale,
@ -249,37 +288,69 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
zoom.transform, zoom.transform,
d3.zoomIdentity d3.zoomIdentity
.translate(initialTranslate[0], initialTranslate[1]) .translate(initialTranslate[0], initialTranslate[1])
.scale(initialScale), .scale(initialScale)
); );
// Fix center node position // Fix center node
const centerNode = nodes.find((n) => n.isCenter); const centerNode = nodes.find((n) => n.isCenter);
const categoryNodes = nodes.filter((n) => !n.isCenter && n.stageid === -1);
if (centerNode) { if (centerNode) {
centerNode.fx = width / 2; const centerX = width / 2;
centerNode.fy = height / 2; const centerY = height / 2;
centerNode.fx = centerX;
centerNode.fy = centerY;
const baseRadius = 450; // شعاع پایه
const variation = 100; // تغییر طول یکی در میان
const angleStep = (2 * Math.PI) / categoryNodes.length;
categoryNodes.forEach((catNode, i) => {
const angle = i * angleStep;
const radius = baseRadius + (i % 2 === 0 ? -variation : variation);
catNode.fx = centerX + radius * Math.cos(angle);
catNode.fy = centerY + radius * Math.sin(angle);
});
} }
// Create links // نودهای نهایی **هیچ fx/fy نداشته باشند**
// فقط forceLink آن‌ها را به دسته‌ها متصل نگه می‌دارد
// const finalNodes = nodes.filter(n => !n.isCenter && n.stageid !== -1);
// categoryNodes.forEach((catNode) => {
// const childNodes = finalNodes.filter(n => n.category === catNode.category);
// const childCount = childNodes.length;
// const radius = 100; // فاصله از دسته
// const angleStep = (2 * Math.PI) / childCount;
// childNodes.forEach((node, i) => {
// const angle = i * angleStep;
// node.fx = catNode.fx! + radius * Math.cos(angle);
// node.fy = catNode.fy! + radius * Math.sin(angle);
// });
// });
// Curved links
const link = container const link = container
.selectAll(".link") .selectAll(".link")
.data(links) .data(links)
.enter() .enter()
.append("line") .append("path")
.attr("class", "link") .attr("class", "link")
.attr("stroke", "#E2E8F0") .attr("stroke", "#E2E8F0")
.attr("stroke-width", 2) .attr("stroke-width", 2)
.attr("stroke-opacity", 0.6); .attr("stroke-opacity", 0.6)
.attr("fill", "none");
// Create node groups
const nodeGroup = container const nodeGroup = container
.selectAll(".node") .selectAll(".node")
.data(nodes) .data(nodes)
.enter() .enter()
.append("g") .append("g")
.attr("class", "node") .attr("class", "node")
.style("cursor", "pointer"); .style("cursor", (d) => (d.stageid === -1 ? "default" : "pointer"));
// Add drag behavior
const drag = d3 const drag = d3
.drag<SVGGElement, Node>() .drag<SVGGElement, Node>()
.on("start", (event, d) => { .on("start", (event, d) => {
@ -301,18 +372,65 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
nodeGroup.call(drag); nodeGroup.call(drag);
// Add node circles/rectangles
nodeGroup.each(function (d) { nodeGroup.each(function (d) {
const group = d3.select(this); const group = d3.select(this);
// if (d.isCenter) {
// const rect = group
// .append("rect")
// .attr("width", 200)
// .attr("height", 80)
// .attr("x", -100) // نصف عرض جدید منفی
// .attr("y", -40) // نصف ارتفاع جدید منفی
// .attr("rx", 8)
// .attr("ry", 8)
// .attr("fill", categoryToColor[d.category] || "#94A3B8")
// .attr("stroke", "#FFFFFF")
// .attr("stroke-width", 3)
// .style("pointer-events", "none");
// if (d.imageUrl || d.isCenter) {
// const pattern = defs
// .append("pattern")
// .attr("id", `image-${d.id}`)
// .attr("x", 0)
// .attr("y", 0)
// .attr("width", 1)
// .attr("height", 1);
// pattern
// .append("image")
// .attr("x", 0)
// .attr("y", 0)
// .attr("width", 200) // ← هم‌اندازه با مستطیل
// .attr("height", 80)
// .attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
// .attr("preserveAspectRatio", "xMidYMid slice");
// rect.attr("fill", `url(#image-${d.id})`);
// }
// }
// راه حل ساده‌تر - ابعاد ثابت با حفظ نسبت
if (d.isCenter) { if (d.isCenter) {
// Center node as rectangle
//آپادانا
const fixedWidth = 198;
const fixedHeight = 200; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
//بندر امام
// const fixedWidth = 100;
// const fixedHeight = 80; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
//نوری
// const fixedWidth = 100;
// const fixedHeight = 80; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
const rect = group const rect = group
.append("rect") .append("rect")
.attr("width", 150) .attr("width", fixedWidth)
.attr("height", 60) .attr("height", fixedHeight)
.attr("x", -75) .attr("x", -fixedWidth / 2)
.attr("y", -30) .attr("y", -fixedHeight / 2)
.attr("rx", 8) .attr("rx", 8)
.attr("ry", 8) .attr("ry", 8)
.attr("fill", categoryToColor[d.category] || "#94A3B8") .attr("fill", categoryToColor[d.category] || "#94A3B8")
@ -320,8 +438,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("stroke-width", 3) .attr("stroke-width", 3)
.style("pointer-events", "none"); .style("pointer-events", "none");
// Add center image if available
if (d.imageUrl || d.isCenter) {
const pattern = defs const pattern = defs
.append("pattern") .append("pattern")
.attr("id", `image-${d.id}`) .attr("id", `image-${d.id}`)
@ -334,23 +450,22 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.append("image") .append("image")
.attr("x", 0) .attr("x", 0)
.attr("y", 0) .attr("y", 0)
.attr("width", 150) .attr("width", fixedWidth)
.attr("height", 60) .attr("height", fixedHeight)
.attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl) .attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
.attr("preserveAspectRatio", "xMidYMid slice"); .attr("preserveAspectRatio", "xMidYMid meet"); // حفظ نسبت تصویر
rect.attr("fill", `url(#image-${d.id})`); rect.attr("fill", `url(#image-${d.id})`);
} }
} else { else {
// Regular nodes as circles
const circle = group const circle = group
.append("circle") .append("circle")
.attr("r", 25) .attr("r", 25)
.attr("fill", categoryToColor[d.category] || "8#fff") .attr("fill", categoryToColor[d.category] || "#fff")
.attr("stroke", "#FFFFFF") .attr("stroke", "#FFFFFF")
.attr("stroke-width", 3); .attr("stroke-width", 3);
// Add node image if available
if (d.imageUrl) { if (d.imageUrl) {
const pattern = defs const pattern = defs
.append("pattern") .append("pattern")
@ -367,10 +482,8 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("width", 50) .attr("width", 50)
.attr("height", 50) .attr("height", 50)
.attr("href", d.imageUrl) .attr("href", d.imageUrl)
.attr("backgroundColor", "#fff")
.attr("preserveAspectRatio", "xMidYMid slice"); .attr("preserveAspectRatio", "xMidYMid slice");
// Create circular clip path
defs defs
.append("clipPath") .append("clipPath")
.attr("id", `clip-${d.id}`) .attr("id", `clip-${d.id}`)
@ -384,12 +497,26 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
} }
}); });
// Add labels below nodes
const labels = nodeGroup const labels = nodeGroup
.append("text") .append("text")
.text((d) => d.label) .text((d) => d.label)
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("dy", (d) => (d.isCenter ? 50 : 45)) .attr("dy", (d) => {
if (d.isCenter) {
//آپادانا
const centerNodeHeight = 200; // ارتفاع نود مرکزی
//بندر امام
// const centerNodeHeight = 80; // ارتفاع نود مرکزی
//نوری
// const centerNodeHeight = 80; // ارتفاع نود مرکزی
return centerNodeHeight / 2 + 20; // نصف ارتفاع + فاصله 20px
}
return 45; // برای نودهای دیگر
})
.attr("font-size", (d) => (d.isCenter ? "14px" : "12px")) .attr("font-size", (d) => (d.isCenter ? "14px" : "12px"))
.attr("font-weight", "bold") .attr("font-weight", "bold")
.attr("fill", "#F9FAFB") .attr("fill", "#F9FAFB")
@ -397,7 +524,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("stroke-width", 4) .attr("stroke-width", 4)
.attr("paint-order", "stroke"); .attr("paint-order", "stroke");
// Add hover effects
nodeGroup nodeGroup
.on("mouseenter", function (event, d) { .on("mouseenter", function (event, d) {
if (d.isCenter) return; if (d.isCenter) return;
@ -419,34 +545,45 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("stroke-width", 3); .attr("stroke-width", 3);
}); });
// Add click handlers
nodeGroup.on("click", async function (event, d) { nodeGroup.on("click", async function (event, d) {
event.stopPropagation(); event.stopPropagation();
// Don't handle center node clicks // جلوگیری از کلیک روی مرکز و دسته‌بندی‌ها
if (d.isCenter) return; if (d.isCenter || d.stageid === -1) return;
if (onNodeClick && d.stageid) { if (onNodeClick && d.stageid) {
try { // Open dialog immediately with basic info
// Fetch detailed company data const basicDetails: CompanyDetails = {
const res = await callAPI(d.stageid); id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: [],
};
onNodeClick(basicDetails);
// Start loading
onLoadingChange?.(true);
try {
if (date.start && date.end) {
const res = await callAPI(d.stageid);
const responseData = JSON.parse(res.data); const responseData = JSON.parse(res.data);
const fieldValues = const fieldValues =
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || []; JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
// Filter out image fields and find description
const filteredFields = fieldValues.filter( const filteredFields = fieldValues.filter(
(field: any) => (field: any) =>
!["image", "img", "full_name", "about_collaboration"].includes( !["image", "img", "full_name", "about_collaboration"].includes(
field.F.toLowerCase(), field.F.toLowerCase()
), )
); );
const descriptionField = fieldValues.find( const descriptionField = fieldValues.find(
(field: any) => (field: any) =>
field.F.toLowerCase().includes("description") || field.F.toLowerCase().includes("description") ||
field.F.toLowerCase().includes("about_collaboration") || field.F.toLowerCase().includes("about_collaboration") ||
field.F.toLowerCase().includes("about"), field.F.toLowerCase().includes("about")
); );
const companyDetails: CompanyDetails = { const companyDetails: CompanyDetails = {
@ -459,39 +596,37 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
}; };
onNodeClick(companyDetails); onNodeClick(companyDetails);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch company details:", error); console.error("Failed to fetch company details:", error);
// Fallback to basic info // Keep the basic details already shown
const basicDetails: CompanyDetails = { } finally {
id: d.id, // Stop loading
label: d.label, onLoadingChange?.(false);
category: d.category,
stageid: d.stageid,
fields: [],
};
onNodeClick(basicDetails);
} }
} }
}); });
// Update positions on simulation tick
simulation.on("tick", () => { simulation.on("tick", () => {
link link.attr("d", (d: any) => {
.attr("x1", (d) => (d.source as Node).x!) const sx = (d.source as Node).x!;
.attr("y1", (d) => (d.source as Node).y!) const sy = (d.source as Node).y!;
.attr("x2", (d) => (d.target as Node).x!) const tx = (d.target as Node).x!;
.attr("y2", (d) => (d.target as Node).y!); const ty = (d.target as Node).y!;
const dx = tx - sx;
const dy = ty - sy;
const dr = Math.sqrt(dx * dx + dy * dy) * 1.2; // منحنی
return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
});
nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`); nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`);
}); });
// Cleanup function
return () => { return () => {
simulation.stop(); simulation.stop();
}; };
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]); }, [nodes, links, isLoading, isMounted, onNodeClick, callAPI, date]);
// Show error message
if (error) { if (error) {
return ( return (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800"> <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
@ -505,7 +640,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
); );
} }
// Don't render on server side
if (!isMounted) { if (!isMounted) {
return ( return (
<div className="w-full h-full flex items-center justify-center bg-transparent"> <div className="w-full h-full flex items-center justify-center bg-transparent">
@ -519,14 +653,11 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="w-full h-full relative bg-transparent"> <div className="w-full h-full relative bg-transparent">
{/* Skeleton Graph Container */}
<div className="w-full h-full flex items-center justify-center relative"> <div className="w-full h-full flex items-center justify-center relative">
{/* Center Node Skeleton */}
<div className="w-12 h-12 rounded-lg bg-gray-600 animate-pulse relative z-10"> <div className="w-12 h-12 rounded-lg bg-gray-600 animate-pulse relative z-10">
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div> <div className="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
</div> </div>
{/* Outer Ring Nodes Skeleton */}
{Array.from({ length: 8 }).map((_, i) => { {Array.from({ length: 8 }).map((_, i) => {
const angle = (i * 2 * Math.PI) / 8; const angle = (i * 2 * Math.PI) / 8;
const radius = 120; const radius = 120;
@ -547,40 +678,25 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
<div <div
className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse" className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse"
style={{ style={{
left: "50%", transform: `rotate(${(i * 360) / 8}deg) translateX(32px)`,
top: "40px", transformOrigin: "left center",
transform: "translateX(-50%)",
animationDelay: `${i * 200 + 100}ms`,
}}
></div>
<div
className="absolute w-0.5 bg-gray-600 animate-pulse opacity-30"
style={{
left: "50%",
top: "50%",
height: `${radius - 16}px`,
transformOrigin: "top",
transform: `translateX(-50%) rotate(${angle + Math.PI}rad)`,
animationDelay: `${i * 100}ms`,
}} }}
></div> ></div>
</div> </div>
); );
})} })}
</div> </div>
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
<div className="text-white font-persian text-sm animate-pulse">
در حال بارگذاری نمودار...
</div>
</div>
</div> </div>
); );
} }
return ( return (
<div className="w-full h-full relative bg-transparent overflow-hidden"> <div className="w-full h-full">
<svg ref={svgRef} className="w-full h-full" style={{ minHeight: 500 }} /> <svg
ref={svgRef}
className="w-full h-full bg-transparent"
style={{ cursor: "grab" }}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,67 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import React from "react";
interface MonthItem {
id: string;
label: string;
start: string;
end: string;
}
// interface CurrentDay {
// start: string;
// end: string;
// month: string;
// }
interface CalendarProps {
title: string;
nextYearHandler: () => void;
prevYearHandler: () => void;
currentYear?: number;
monthList: Array<MonthItem>;
selectedDate?: string;
selectDateHandler: (item: MonthItem) => void;
}
export const Calendar: React.FC<CalendarProps> = ({
title,
nextYearHandler,
prevYearHandler,
currentYear,
monthList,
selectedDate,
selectDateHandler,
}) => {
return (
<div className="filter-box bg-pr-gray w-full px-1">
<header className="flex flex-row border-b border-[#5F6284] pb-1.5 justify-center">
<span className="font-light">{title}</span>
<div className="flex flex-row items-center gap-3">
<ChevronRight
className="inline-block w-6 h-6 cursor-pointer"
onClick={nextYearHandler}
/>
<span className="font-light">{currentYear}</span>
<ChevronLeft
className="inline-block w-6 h-6 cursor-pointer"
onClick={prevYearHandler}
/>
</div>
</header>
<div className="content flex flex-col gap-2 text-center pt-1 cursor-pointer">
{monthList.map((item, index) => (
<span
key={`${item.id}-${index}`}
className={`text-lg hover:bg-[#33364D] p-1 rounded-xl transition-all duration-300 ${
selectedDate === item.label ? `bg-[#33364D]` : ""
}`}
onClick={() => selectDateHandler(item)}
>
{item.label}
</span>
))}
</div>
</div>
);
};

View File

@ -23,22 +23,34 @@ export function BaseCard({
return ( return (
<Card <Card
className={cn( className={cn(
"bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm py-4 grid items-center", "bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm py-2 pb-0 grid items-center",
className className
)} )}
> >
{Icon && title ? ( {Icon && title ? (
<CardHeader className={cn("border-b-2 border-gray-500/20 py-2 px-0 pb-4", headerClassName)}> <CardHeader
<CardTitle className="text-white text-sm text-right font-persian px-4 my-auto items-center flex w-full justify-between">{title} {<Icon />} </CardTitle> className={cn(
"border-b-2 border-gray-500/20 py-2 px-0 pb-4",
headerClassName
)}
>
<CardTitle className="text-white text-sm text-right font-persian px-4 my-auto items-center flex w-full justify-between">
{title} {<Icon />}{" "}
</CardTitle>
</CardHeader> </CardHeader>
) : ) : withHeader && title ? (
withHeader && title ? ( <CardHeader
<CardHeader className={cn("pb-2 border-b-2 border-gray-500/20", headerClassName)}> className={cn("pb-2 border-b-2 border-gray-500/20", headerClassName)}
<CardTitle className="text-white text-sm text-right font-persian px-4">{title}</CardTitle> >
<CardTitle className="text-white text-sm text-right font-persian px-4">
{title}
</CardTitle>
</CardHeader> </CardHeader>
) : title ? ( ) : title ? (
<div className="border-b-2 border-gray-500/20 pb-2"> <div className="border-b-2 border-gray-500/20 pb-2">
<h3 className="text-sm font-bold text-white text-right font-persian px-4">{title}</h3> <h3 className="text-sm font-bold text-white text-right font-persian px-4">
{title}
</h3>
</div> </div>
) : null} ) : null}
<CardContent className={cn("py-2 px-4 ", contentClassName)}> <CardContent className={cn("py-2 px-4 ", contentClassName)}>

View File

@ -1,4 +1,4 @@
import { formatNumber } from "~/lib/utils"; import { calculateNiceRange, formatNumber } from "~/lib/utils";
export interface BarChartData { export interface BarChartData {
label: string; label: string;
@ -18,6 +18,7 @@ interface CustomBarChartProps {
showAxisLabels?: boolean; showAxisLabels?: boolean;
className?: string; className?: string;
loading?: boolean; loading?: boolean;
hasPercent?: boolean;
} }
export function CustomBarChart({ export function CustomBarChart({
@ -28,16 +29,17 @@ export function CustomBarChart({
showAxisLabels = true, showAxisLabels = true,
className = "", className = "",
loading = false, loading = false,
hasPercent = true,
}: CustomBarChartProps) { }: CustomBarChartProps) {
// Calculate the maximum value across all data points for consistent scaling // استفاده از nice numbers برای محاسبه دامنه مناسب
const globalMaxValue = Math.max( const values = data.map((item) => item.maxValue || item.value);
...data.map((item) => item.maxValue || item.value) const { niceMax, ticks } = calculateNiceRange(values, 0, 5);
); const globalMaxValue = niceMax;
// Loading skeleton // Loading skeleton
if (loading) { if (loading) {
return ( return (
<div className={`space-y-6 p-4 ${className}`} style={{ height }}> <div className={`space-y-6 p-4 pt-0 ${className}`} style={{ height }}>
{title && ( {title && (
<div className="h-7 bg-gray-600 rounded animate-pulse mb-4 w-1/2"></div> <div className="h-7 bg-gray-600 rounded animate-pulse mb-4 w-1/2"></div>
)} )}
@ -67,22 +69,24 @@ export function CustomBarChart({
return ( return (
<div className={`space-y-6 ${className}`} style={{ height }}> <div className={`space-y-6 ${className}`} style={{ height }}>
{title && <div className="border-b-[#3F415A] border-b-2"> {title && (
<div className="border-b-[#3F415A] border-b-2">
<h3 className="text-sm font-semibold text-white font-persian text-right p-4"> <h3 className="text-sm font-semibold text-white font-persian text-right px-4 pb-3">
{title} {title}
</h3> </h3>
</div>} </div>
)}
<div className="space-y-4 px-4 pb-4"> <div className="space-y-4 px-4 pb-4">
{data.map((item, index) => { {data.map((item, index) => {
// محاسبه درصد بر اساس nice max value
const percentage = const percentage =
globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0; globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0;
const displayValue: any = item.value; const displayValue: any = item.value;
return ( return (
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-3">
<span <span
className={`font-persian text-sm font-normal min-w-[120px] text-right ${ className={`font-persian text-sm font-normal min-w-[120px] text-left ${
item.labelColor || "text-white" item.labelColor || "text-white"
}`} }`}
> >
@ -101,11 +105,12 @@ export function CustomBarChart({
> >
<div className="inset-0 bg-gradient-to-r from-transparent to-white/10 rounded-full"></div> <div className="inset-0 bg-gradient-to-r from-transparent to-white/10 rounded-full"></div>
</div> </div>
<span
className={`text-base font-normal text-left text-white`} <span className={`text-base font-normal text-left text-white`}>
>
{item.valuePrefix || ""} {item.valuePrefix || ""}
{formatNumber(parseFloat(displayValue))} {formatNumber(parseFloat(displayValue))}
{hasPercent ? "%" : ""}
{item.valueSuffix || ""} {item.valueSuffix || ""}
</span> </span>
</div> </div>
@ -113,24 +118,16 @@ export function CustomBarChart({
); );
})} })}
{/* Axis Labels */} {/* Axis Labels با استفاده از nice numbers */}
{showAxisLabels && globalMaxValue > 0 && ( {showAxisLabels && globalMaxValue > 0 && (
<div className="flex w-full items-center gap-3 mt-6"> <div className="flex w-full items-center gap-3 mt-6">
<span className="min-w-[120px]"></span> <span className="min-w-[120px]"></span>
<div className="flex-1 flex justify-between pt-2 border-t border-gray-700"> <div className="flex-1 flex justify-between pt-2 border-t border-gray-700">
<span className="text-gray-400 text-xs">{formatNumber(0)}</span> {ticks.map((tick, index) => (
<span className="text-gray-400 text-xs"> <span key={index} className="text-gray-400 text-xs">
{formatNumber(Math.round(globalMaxValue / 4))} {formatNumber(tick)}%
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(globalMaxValue / 2))}
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round((globalMaxValue * 3) / 4))}
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(globalMaxValue))}
</span> </span>
))}
</div> </div>
<span className="min-w-[0px]"></span> <span className="min-w-[0px]"></span>
</div> </div>

View File

@ -1,18 +1,18 @@
"use client" "use client";
import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
{...props} {...props}
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -59,13 +59,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col p-4 space-y-1.5 text-center sm:text-left",
className className
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -78,8 +78,8 @@ const DialogFooter = ({
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,18 +105,18 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
DialogPortal,
DialogOverlay,
DialogClose, DialogClose,
DialogTrigger,
DialogContent, DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription, DialogDescription,
} DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -1,27 +1,27 @@
"use client" "use client";
import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { Check, ChevronDown, Circle } from "lucide-react";
import { Check, ChevronRight, Circle } from "lucide-react" import * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -34,11 +34,10 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -52,9 +51,9 @@ const DropdownMenuSubContent = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +64,34 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 min-w-[8rem] overflow-hidden mt-1 rounded-xl border border-gray-500 bg-pr-gray p-1 text-popover-foreground shadow-md 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",
className className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
selected?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, selected, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "cursor-pointer select-none rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-dark-blue data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
selected && "bg-dark-blue text-white",
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -112,9 +113,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -135,13 +136,13 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -153,8 +154,8 @@ const DropdownMenuLabel = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -165,8 +166,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@ -177,24 +178,43 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
const DropdownMenuButton = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.Trigger
ref={ref}
className={cn(
"flex items-center justify-between gap-2 text-sm outline-none border border-gray-500 p-3 rounded-xl min-w-50 max-w-72 group",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</DropdownMenuPrimitive.Trigger>
));
DropdownMenuButton.displayName = "DropdownMenuButton";
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuButton,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioItem, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuTrigger,
} };

View File

@ -13,6 +13,7 @@ interface FunnelChartProps {
title?: string; title?: string;
className?: string; className?: string;
} }
const greenColors = ["#3C9F71","#3BC47A","#3BC47A","#3BD77E","#3AEA83"]
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) { export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
const maxValue = Math.max(...data.map(d => d.value)); const maxValue = Math.max(...data.map(d => d.value));
@ -50,15 +51,15 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
return ( return (
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full"> <div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
<div className="text-sm font-light text-white cols-start-1 justify-self-start font-thin min-w-[max-content] text-center"> <div className="text-sm font-light text-white font-persian cols-start-1 justify-self-start min-w-[max-content] text-center">
{item.label} {item.label}
</div> </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 gap-10 w-full cols-start-2 justify-center">
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div style={{ width: `${(100 - barWidth) / 2}%` }} /> <div style={{ width: `${(100 - barWidth) / 2}%` }} />
<div <div
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative" className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
style={{ width: `${barWidth}%` }} style={{ width: `${barWidth}%` ,backgroundColor : `${greenColors[index]}`}}
> >
<span className="text-pr-gray text-base font-semibold"> <span className="text-pr-gray text-base font-semibold">
{item.value.toLocaleString('fa-IR')} {item.value.toLocaleString('fa-IR')}

View File

@ -17,9 +17,9 @@ export function MetricCard({
percentLabel = "درصد به کل", percentLabel = "درصد به کل",
}: MetricCardProps) { }: MetricCardProps) {
return ( return (
<BaseCard title={title}> <BaseCard title={title} className="h-full">
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 h-full">
<div className="text-center"> <div className="text-center">
<p className="text-3xl font-bold text-green-400"> <p className="text-3xl font-bold text-green-400">
{formatNumber(value)} {formatNumber(value)}

View File

@ -1,26 +1,51 @@
import * as React from "react" import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "~/lib/utils" import { cn, formatNumber } from "~/lib/utils"
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => ( >(({ className, value, ...props }, ref) => {
// Dynamic scaling logic based on value ranges
const getScaledValue = (inputValue: number) => {
const numValue = Number(inputValue);
if (numValue <= 1) {
return numValue * 100;
}
else if (numValue <= 10) {
return (numValue / 10) * 100;
} else if (numValue <= 50) {
return (numValue / 50) * 100;
}
else {
return numValue
}
};
const scaledValue = getScaledValue(Number(value) || 0);
const displayValue = Number(value) || 0;
return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary", "relative h-4 w-full overflow-hidden rounded-full bg-pr-gray",
className className
)} )}
{...props} {...props}
> >
<span className="left-0 text-sm absolute z-10 px-2 text-[#5F6284]">۰%</span>
<span className="w-full right-0 text-sm absolute z-10 px-2 text-[#5F6284]">
{formatNumber(displayValue.toFixed(2))}%
</span>
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all" className="h-full w-full flex-1 bg-pr-green transition-all z-20"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100-scaledValue}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)) )
})
Progress.displayName = ProgressPrimitive.Root.displayName Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress } export { Progress }

View File

@ -4,11 +4,12 @@ import { cn } from "~/lib/utils"
interface TableProps extends React.HTMLAttributes<HTMLTableElement> { interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
containerClassName?: string containerClassName?: string
containerRef?: React.RefObject<HTMLDivElement | null>
} }
const Table = React.forwardRef<HTMLTableElement, TableProps>( const Table = React.forwardRef<HTMLTableElement, TableProps>(
({ className, containerClassName, ...props }, ref) => ( ({ className, containerClassName, containerRef, ...props }, ref) => (
<div className={cn("relative w-full", containerClassName)}> <div ref={containerRef} className={cn("relative w-full", containerClassName)}>
<table <table
ref={ref} ref={ref}
className={cn("w-full caption-bottom text-sm h-full", className)} className={cn("w-full caption-bottom text-sm h-full", className)}

View File

@ -81,7 +81,7 @@ export function TabsTrigger({
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive isActive
? "bg-gray-700 text-foreground shadow-sm" ? "bg-pr-gray text-foreground shadow-sm"
: "hover:bg-muted/50", : "hover:bg-muted/50",
className, className,
)} )}

View File

@ -52,7 +52,7 @@ function TooltipContent({
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className={cn("bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]",className)} />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

View File

@ -0,0 +1,27 @@
import jalaali from "jalaali-js";
import { useEffect, useState } from "react";
import type { CalendarDate } from "~/types/util.type";
const { jy } = jalaali.toJalaali(new Date());
export function useStoredDate(): [
CalendarDate,
React.Dispatch<React.SetStateAction<CalendarDate>>,
] {
const [date, setDate] = useState<CalendarDate>({});
useEffect(() => {
const storedDate = localStorage.getItem("dateSelected");
if (storedDate) {
setDate(JSON.parse(storedDate));
} else {
setDate({
start: `${jy}/01/01`,
end: `${jy}/12/30`,
});
}
}, [jy]);
return [date, setDate];
}

View File

@ -162,10 +162,24 @@ class ApiService {
// Innovation process function call wrapper // Innovation process function call wrapper
public async call<T = any>(payload: any) { public async call<T = any>(payload: any) {
//بندر امام
const url = "https://inogen-back.pelekan.org/api/call"; const url = "https://inogen-back.pelekan.org/api/call";
//آپادانا
const url = "https://APADANA-IATM-back.pelekan.org/api/call";
//نوری
const url = "https://NOPC-IATM-back.pelekan.org/api/call";
return this.postAbsolute<T>(url, payload); return this.postAbsolute<T>(url, payload);
} }
const API_BASE_URL =
//بندر امام
// import.meta.env.VITE_API_URL || "https://inogen-bpms-back.pelekan.org/api";
//آپادانا
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
//نوری
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
// GET request // GET request
public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> { public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { return this.request<T>(endpoint, {

View File

@ -1,6 +1,7 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import EventEmitter from "events";
import moment from "moment-jalaali"; import moment from "moment-jalaali";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -22,7 +23,97 @@ export const formatCurrency = (amount: string | number) => {
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
}; };
/**
* محاسبه دامنه nice numbers برای محور Y نمودارها
* @param values آرایه از مقادیر دادهها
* @param minValue حداقل مقدار (پیش‌فرض: 0 برای دادههای درصدی)
* @param marginPercent درصد حاشیه اضافی (پیش‌فرض: 5%)
* @returns شیء شامل حداکثر nice، فاصله tick ها، و آرایه tick ها
*/
export function calculateNiceRange(
values: number[],
minValue: number = 0,
marginPercent: number = 5
): {
niceMax: number;
tickInterval: number;
ticks: number[];
} {
if (values.length === 0) {
return { niceMax: 100, tickInterval: 20, ticks: [0, 20, 40, 60, 80, 100] };
}
// پیدا کردن حداکثر مقدار در داده‌ها
const dataMax = Math.max(...values);
// اگر همه مقادیر صفر یا منفی هستند
if (dataMax <= 0) {
return { niceMax: 100, tickInterval: 20, ticks: [0, 20, 40, 60, 80, 100] };
}
// اضافه کردن حاشیه
const maxWithMargin = dataMax * (1 + marginPercent / 100);
// محاسبه nice upper limit
const niceMax = calculateNiceNumber(maxWithMargin, true);
// محاسبه فاصله مناسب tick ها بر اساس niceMax
const range = niceMax - minValue;
const targetTicks = 5; // هدف: 5 tick
const roughTickInterval = range / (targetTicks - 1);
const niceTickInterval = calculateNiceNumber(roughTickInterval, false);
// ایجاد آرایه tick ها
const ticks: number[] = [];
for (let i = minValue; i <= niceMax; i += niceTickInterval) {
ticks.push(Math.round(i));
}
// اطمینان از اینکه niceMax در آرایه tick ها باشد
if (ticks[ticks.length - 1] !== niceMax) {
ticks.push(niceMax);
}
return {
niceMax,
tickInterval: niceTickInterval,
ticks,
};
}
/**
* محاسبه عدد nice (گرد و خوانا) بر اساس الگوریتم nice numbers
* @param value مقدار ورودی
* @param round آیا به سمت بالا گرد شود یا نه
* @returns عدد nice
*/
function calculateNiceNumber(value: number, round: boolean): number {
if (value <= 0) return 0;
// پیدا کردن قدرت 10
const exponent = Math.floor(Math.log10(value));
const fraction = value / Math.pow(10, exponent);
let niceFraction: number;
if (round) {
// برای حداکثر: به سمت بالا گرد می‌کنیم با دقت بیشتر
if (fraction <= 1.0) niceFraction = 1;
else if (fraction <= 2.0) niceFraction = 2;
else if (fraction <= 2.5) niceFraction = 2.5;
else if (fraction <= 5.0) niceFraction = 5;
else if (fraction <= 7.5) niceFraction = 7.5;
else niceFraction = 10;
} else {
// برای فاصله tick ها: اعداد ساده‌تر
if (fraction <= 1.0) niceFraction = 1;
else if (fraction <= 2.0) niceFraction = 2;
else if (fraction <= 5.0) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
}
export const handleDataValue = (val: any): any => { export const handleDataValue = (val: any): any => {
moment.loadPersian({ usePersianDigits: true }); moment.loadPersian({ usePersianDigits: true });
@ -40,4 +131,6 @@ moment.loadPersian({ usePersianDigits: true });
return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
} }
return val; return val;
} };
export const EventBus = new EventEmitter();

View File

@ -1,27 +1,33 @@
import type { Route } from "./+types/ecosystem"; import moment from "moment-jalaali";
import React from "react"; import React from "react";
import { ProtectedRoute } from "~/components/auth/protected-route"; import { ProtectedRoute } from "~/components/auth/protected-route";
import { DashboardLayout } from "~/components/dashboard/layout"; import { DashboardLayout } from "~/components/dashboard/layout";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { InfoPanel } from "~/components/ecosystem/info-panel";
import { NetworkGraph } from "~/components/ecosystem/network-graph";
import { Card, CardContent } from "~/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { NetworkGraph } from "~/components/ecosystem/network-graph";
import { InfoPanel } from "~/components/ecosystem/info-panel";
import { useAuth } from "~/contexts/auth-context"; import { useAuth } from "~/contexts/auth-context";
import moment from "moment-jalaali"; import type { Route } from "./+types/ecosystem";
// Get API base URL at module level to avoid process.env access in browser // Get API base URL at module level to avoid process.env access in browser
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api"; //بندر امام
// import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
//آپادانا
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
//نوری
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
// Import the CompanyDetails type // Import the CompanyDetails type
import { Hexagon } from "lucide-react";
import type { CompanyDetails } from "~/components/ecosystem/network-graph"; import type { CompanyDetails } from "~/components/ecosystem/network-graph";
import { formatNumber } from "~/lib/utils";
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [
@ -55,10 +61,20 @@ function handleValue(val: any): any {
export default function EcosystemPage() { export default function EcosystemPage() {
const [selectedCompany, setSelectedCompany] = const [selectedCompany, setSelectedCompany] =
React.useState<CompanyDetails | null>(null); React.useState<CompanyDetails | null>(null);
const [isDialogLoading, setIsDialogLoading] = React.useState(false);
const { token } = useAuth(); const { token } = useAuth();
const closeDialog = () => { const closeDialog = () => {
setSelectedCompany(null); setSelectedCompany(null);
setIsDialogLoading(false);
};
const handleNodeClick = (company: CompanyDetails) => {
setSelectedCompany(company);
};
const handleLoadingChange = (loading: boolean) => {
setIsDialogLoading(loading);
}; };
// Construct image URL // Construct image URL
@ -69,7 +85,7 @@ export default function EcosystemPage() {
return ( return (
<ProtectedRoute requireAuth={true}> <ProtectedRoute requireAuth={true}>
<DashboardLayout title="زیست بوم فناوری"> <DashboardLayout title="زیست بوم فناوری">
<div className="p-4 lg:p-6"> <div>
<div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4"> <div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4">
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<InfoPanel selectedCompany={selectedCompany} /> <InfoPanel selectedCompany={selectedCompany} />
@ -78,7 +94,10 @@ export default function EcosystemPage() {
<div className="lg:col-span-8 h-full"> <div className="lg:col-span-8 h-full">
<Card className="h-full overflow-hidden bg-transparent border-[#3F415A]"> <Card className="h-full overflow-hidden bg-transparent border-[#3F415A]">
<CardContent className="p-0 h-full bg-transparent"> <CardContent className="p-0 h-full bg-transparent">
<NetworkGraph onNodeClick={setSelectedCompany} /> <NetworkGraph
onNodeClick={handleNodeClick}
onLoadingChange={handleLoadingChange}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -90,7 +109,7 @@ export default function EcosystemPage() {
open={!!selectedCompany} open={!!selectedCompany}
onOpenChange={(open) => !open && closeDialog()} onOpenChange={(open) => !open && closeDialog()}
> >
<DialogContent className="font-persian max-w-6xl max-h-[75vh] overflow-y-auto bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)]"> <DialogContent className="font-persian max-w-6xl min-h-max bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-right border-b-2 border-gray-600 pt-2 pb-4 mr-4 text-sm font-semibold"> <DialogTitle className="text-right border-b-2 border-gray-600 pt-2 pb-4 mr-4 text-sm font-semibold">
معرفی معرفی
@ -98,7 +117,44 @@ export default function EcosystemPage() {
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {isDialogLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-6">
{/* Right Column - Loading Skeleton */}
<div className="space-y-4 p-6 border-l-2 border-gray-600">
{/* Company Image & Title Skeleton */}
<div className="flex justify-between px-10 items-center mb-4">
<div className="h-8 bg-gray-600 rounded animate-pulse w-48"></div>
<div className="w-12 h-12 bg-gray-600 rounded-2xl animate-pulse"></div>
</div>
{/* Description Skeleton */}
<div className="p-4 rounded-lg space-y-2">
<div className="h-4 bg-gray-600 rounded animate-pulse w-full"></div>
<div className="h-4 bg-gray-600 rounded animate-pulse w-5/6"></div>
<div className="h-4 bg-gray-600 rounded animate-pulse w-4/6"></div>
<div className="h-4 bg-gray-600 rounded animate-pulse w-3/6"></div>
</div>
</div>
{/* Left Column - Loading Skeleton */}
<div className="space-y-2">
<div className="h-6 bg-gray-600 rounded animate-pulse w-32"></div>
<div className="space-y-3 px-2">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="flex justify-between items-center rounded-lg"
>
<div className="flex items-center gap-1">
<div className="h-4 w-4 bg-gray-600 rounded animate-pulse"></div>
<div className="h-4 bg-gray-600 rounded animate-pulse w-24"></div>
</div>
<div className="h-4 bg-gray-600 rounded animate-pulse w-20"></div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="grid p-4 pb-6 grid-cols-1 md:grid-cols-2 gap-6">
{/* Right Column - Description */} {/* Right Column - Description */}
<div className="space-y-4 p-6 border-l-2 border-gray-600"> <div className="space-y-4 p-6 border-l-2 border-gray-600">
{/* Company Image */} {/* Company Image */}
@ -121,7 +177,7 @@ export default function EcosystemPage() {
/> />
) : null} ) : null}
<div <div
className="w-24 h-24 rounded-full bg-gray-600 border-4 border-green-400 flex items-center justify-center" className="w-24 h-24 rounded-full bg-gray-600 border-4 border-pr-green flex items-center justify-center"
style={{ style={{
display: display:
selectedCompany?.stageid && token?.accessToken selectedCompany?.stageid && token?.accessToken
@ -164,18 +220,23 @@ export default function EcosystemPage() {
</h3> </h3>
{selectedCompany?.fields && {selectedCompany?.fields &&
selectedCompany.fields.length > 0 ? ( selectedCompany.fields.length > 0 ? (
<div className="space-y-3 px-4"> <div className="space-y-3 px-2">
{selectedCompany.fields.map((field, index) => ( {selectedCompany.fields.map((field, index) => (
<div <div
key={index} key={index}
className="flex justify-between items-center rounded-lg" className="flex justify-between items-center rounded-lg"
> >
<span className="font-persian text-sm font-light"> <span className="font-persian flex items-center gap-1 text-sm font-light">
<Hexagon className="text-pr-green h-4 w-4" />
{field.N}: {field.N}:
</span> </span>
<span className="font-persian text-sm font-light text-right"> <span className="text-right min-w-1/3">
<span className="font-persian text-sm font-normal text-right">
{handleValue(field.V)} {handleValue(field.V)}
{field.U && <span className="mr-1">({field.U})</span>} {field.U && (
<span className="mr-1">({field.U})</span>
)}
</span>
</span> </span>
</div> </div>
))} ))}
@ -187,6 +248,7 @@ export default function EcosystemPage() {
)} )}
</div> </div>
</div> </div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</DashboardLayout> </DashboardLayout>

View File

@ -3,7 +3,7 @@ import { ManageIdeasTechPage } from "~/components/dashboard/project-management/m
export function meta() { export function meta() {
return [ return [
{ title: "مدیریت فنواری و ایده ها" }, { title: "مدیریت فناوری و ایده ها" },
{ name: "description", content: "مدیریت پروژه‌های فناوری و نوآوری" }, { name: "description", content: "مدیریت پروژه‌های فناوری و نوآوری" },
]; ];
} }

6
app/types/util.type.ts Normal file
View File

@ -0,0 +1,6 @@
export interface CalendarDate {
start?: string;
end?: string;
sinceMonth?: string;
untilMonth?: string;
}

621
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"file-saver": "^2.0.5",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@ -35,11 +36,13 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-router": "^7.7.0", "react-router": "^7.7.0",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"xlsx-js-style": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.7.0", "@react-router/dev": "^7.7.0",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",

File diff suppressed because it is too large Load Diff

View File

@ -17,92 +17,100 @@ This set of fonts are used in this project under the license: (.....)
* *
**/ **/
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
src: url('woff/IRANYekanX-Thin.woff') format('woff'), src:
url('woff2/IRANYekanX-Thin.woff2') format('woff2'); url("/font/woff/IRANYekanX-Thin.woff") format("woff"),
url("/font/woff2/IRANYekanX-Thin.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 200; font-weight: 200;
src: url('woff/IRANYekanX-UltraLight.woff') format('woff'), src:
url('woff2/IRANYekanX-UltraLight.woff2') format('woff2'); url("/font/woff/IRANYekanX-UltraLight.woff") format("woff"),
url("/font/woff2/IRANYekanX-UltraLight.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
src: url('woff/IRANYekanX-Light.woff') format('woff'), src:
url('woff2/IRANYekanX-Light.woff2') format('woff2'); url("/font/woff/IRANYekanX-Light.woff") format("woff"),
url("/font/woff2/IRANYekanX-Light.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
src: url('woff/IRANYekanX-Medium.woff') format('woff'), src:
url('woff2/IRANYekanX-Medium.woff2') format('woff2'); url("/font/woff/IRANYekanX-Medium.woff") format("woff"),
url("/font/woff2/IRANYekanX-Medium.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
src: url('woff/IRANYekanX-DemiBold.woff') format('woff'), src:
url('woff2/IRANYekanX-DemiBold.woff2') format('woff2'); url("/font/woff/IRANYekanX-DemiBold.woff") format("woff"),
url("/font/woff2/IRANYekanX-DemiBold.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
src: url('woff/IRANYekanX-ExtraBold.woff') format('woff'), src:
url('woff2/IRANYekanX-ExtraBold.woff2') format('woff2'); url("/font/woff/IRANYekanX-ExtraBold.woff") format("woff"),
url("/font/woff2/IRANYekanX-ExtraBold.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
src: url('woff/IRANYekanX-Black.woff') format('woff'), src:
url('woff2/IRANYekanX-Black.woff2') format('woff2'); url("/font/woff/IRANYekanX-Black.woff") format("woff"),
url("/font/woff2/IRANYekanX-Black.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 950; font-weight: 950;
src: url('woff/IRANYekanX-ExtraBlack.woff') format('woff'), src:
url('woff2/IRANYekanX-ExtraBlack.woff2') format('woff2'); url("/font/woff/IRANYekanX-ExtraBlack.woff") format("woff"),
url("/font/woff2/IRANYekanX-ExtraBlack.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: 1000; font-weight: 1000;
src: url('woff/IRANYekanX-Heavy.woff') format('woff'), src:
url('woff2/IRANYekanX-Heavy.woff2') format('woff2'); url("/font/woff/IRANYekanX-Heavy.woff") format("woff"),
url("/font/woff2/IRANYekanX-Heavy.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: bold; font-weight: bold;
src: url('woff/IRANYekanX-Bold.woff') format('woff'), src:
url('woff2/IRANYekanX-Bold.woff2') format('woff2'); url("/font/woff/IRANYekanX-Bold.woff") format("woff"),
url("/font/woff2/IRANYekanX-Bold.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: IRANYekanX; font-family: IRANYekanX;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
src: url('woff/IRANYekanX-Regular.woff') format('woff'), src:
url('woff2/IRANYekanX-Regular.woff2') format('woff2'); url("/font/woff/IRANYekanX-Regular.woff") format("woff"),
url("/font/woff2/IRANYekanX-Regular.woff2") format("woff2");
} }