Compare commits

...

61 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
aed286660a refactor_#2 (#13)
Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/13
Co-authored-by: saeed0920 <sd.eed1381@gmail.com>
Co-committed-by: saeed0920 <sd.eed1381@gmail.com>
2025-09-18 10:57:50 +03:30
72 changed files with 8787 additions and 3823 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)}
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>
// <Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer">
// همیشه متصل بمانم
// </Label>
</div>
{/* Submit Button */}

View File

@ -1,13 +1,7 @@
@import url(/font/fontiran.css);
@import "tailwindcss";
/* Persian/Farsi font support */
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap");
@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 */
--color-teal-50: #f0fdfa;
--color-teal-100: #ccfbf1;
@ -34,20 +28,37 @@
--color-slate-900: #0f172a;
--color-slate-950: #020617;
--color-pr-green : #3AEA83;
--color-pr-blue : #69C8EA;
--color-pr-red : #F76276;
--color-pr-green: #3aea83;
--color-pr-blue: #69c8ea;
--color-pr-red: #f76276;
--color-pr-gray: #3f415a;
}
html,
body {
@apply bg-background text-foreground;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
body {
font-family: IRANYekanX;
direction: rtl;
background-color: #cdcdcd;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6,
input,
textarea {
font-family: IRANYekanX;
}
/* RTL Support */
html[dir="rtl"] {
direction: rtl;
@ -67,6 +78,7 @@ html[dir="rtl"] body {
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-dark-blue: var(--dark-blue);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
@ -86,13 +98,13 @@ html[dir="rtl"] body {
:root {
--radius: 0.5rem;
--color-green: #3AEA83;
--color-blue: #69C8EA;
--color-red: #F76276;
--color-green: #3aea83;
--color-blue: #69c8ea;
--color-red: #f76276;
/* primary colors */
--color-pr-gray : #3F415A;
--color-pr-green : var(--color-green);
--color-pr-gray: #3f415a;
--color-pr-green: var(--color-green);
/* Light theme colors */
--background: #ffffff;
@ -114,6 +126,7 @@ html[dir="rtl"] body {
--border: #e5e5e5;
--input: #e5e5e5;
--ring: #22c55e;
--dark-blue: #33364d;
/* Primary color scale */
--color-primary-50: #f0fdf4;
@ -245,29 +258,11 @@ html[dir="rtl"] body {
body {
@apply bg-background text-foreground;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-neutral-100 dark:bg-neutral-800;
}
::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400 dark:bg-neutral-500;
}
}
/* Persian/Farsi font class */
.font-persian {
font-family: "Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif;
font-family: "IRANYekanX";
}
/* Custom utility classes */
@ -427,34 +422,32 @@ html[dir="rtl"] body {
}
.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: .5px solid transparent;
border: 0.5px solid transparent;
background-clip: padding-box;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.8), rgba(16, 185, 129, 1));
}
.dark .custom-scrollbar {
scrollbar-color: rgba(16, 185, 129, 0.6) rgba(30, 41, 59, 0.6); /* thumb track */
}
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.6); /* slate-800 */
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.5), rgba(16, 185, 129, 0.9));
border-color: rgba(30, 41, 59, 0.6);
}
:root {
--form-control-color: #3F415A;
--form-control-disabled: ##5F6284;
--form-background: #3AEA83;
--form-control-color: #3f415a;
--form-control-disabled: ##5f6284;
--form-background: #3aea83;
}
input[type="checkbox"] {
@ -462,11 +455,11 @@ input[type="checkbox"] {
appearance: none;
margin: 0;
font: inherit;
color: #5F6284;
color: #5f6284;
background-color: transparent;
width: 1.15em;
height: 1.15em;
border: 1px solid #5F6284;
border: 1px solid #5f6284;
border-radius: 0.15em;
transform: translateY(-0.075em);
display: grid;
@ -490,7 +483,7 @@ input[type="checkbox"]:checked::before {
}
input[type="checkbox"]:checked {
background-color: #3AEA83 ;
background-color: #3aea83;
border: 1px solid transparent;
}

View File

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

View File

@ -1,6 +1,7 @@
import React from "react";
import { cn } from "~/lib/utils";
interface LoginLayoutProps {
children: React.ReactNode;
className?: string;
@ -106,14 +107,25 @@ export function LoginBranding({
}: LoginBrandingProps) {
return (
<>
{/* Top Logo */}
<div className="flex justify-end">
<div className="text-slate-800 font-persian">
<div className="text-lg font-bold leading-tight">
<img src="/brand.svg" />
</div>
</div>
</div>
<div className="flex justify-end">
<div className="text-slate-800 font-persian">
<div className="text-lg font-bold leading-tight">
<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>
{/* Bottom Section */}
<div className="flex flex-col gap-2 mb-4 items-end justify-end">

View File

@ -1,3 +1,6 @@
//این فایل مخصوص
//شماتیک آپادانا
import React from "react";
import { formatNumber } from "~/lib/utils";
@ -8,10 +11,10 @@ export type CompanyInfo = {
costReduction: number;
revenue?: number;
capacity?: number;
costI : number,
capacityI : number,
revenueI : number,
cost : number | string,
costI: number;
capacityI: number;
revenueI: number;
cost: number | string;
};
export type D3ImageInfoProps = {
@ -20,9 +23,11 @@ export type D3ImageInfoProps = {
height?: number;
};
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 (
<div className={`info-box`} style={style}>
<div className={`info-box`} style={style}>
<div className="info-box-content">
<div className="info-row">
<div className="info-label">درآمد:</div>
@ -31,58 +36,78 @@ const InfoBox = ({ company, style }: { company: CompanyInfo; style :any }) => {
</div>
<div className="info-row">
<div className="info-label">هزینه:</div>
<div className="info-value cost text-[12px]">{formatNumber(company?.cost || 0)}</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>
<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>
{!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 displayCompanies = companies;
// 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
// واحدهای جدید - 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) ;
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"
/>
<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>
{company.name}
</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>
@ -114,20 +139,20 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
.company-image {
object-fit: contain;
height : 100px;
height: 100px;
}
.info-box {
border: 1px solid #3F415A;
border-radius: 10px;
height: max-content;
align-self : center;
justify-self : center;
padding : .2rem 1.2rem;
align-self: center;
justify-self: center;
padding: .2rem 1.2rem;
min-width: 8rem;
background-color: transparent;
}
.info-box-content {
display: flex;
flex-direction: column;
@ -135,16 +160,20 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
}
.info-row {
position : relative;
position: relative;
margin: .1rem 0;
display: flex;
gap : .5rem;
justify-content : space-between;
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-row:has(.info-value.revenue) {
border-bottom: 1px solid #3AEA83;
}
.info-row:has(.info-value.cost) {
border-bottom: 1px solid #F76276;
}
.info-label {
@ -152,7 +181,7 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
font-size: 11px;
font-weight: 300;
text-align: right;
margin : auto 0;
margin: auto 0;
}
.info-value {
@ -160,11 +189,12 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
font-size: 14px;
font-weight: 500;
text-align: right;
margin-bottom : .5rem;
margin-bottom: .5rem;
}
.info-value.revenue { color: #fff;}
.info-value.revenue { color: #fff; }
.info-value.cost { color: #fff; }
.info-value.cost2 { color: #fff; }
.info-value.capacity { color: #fff; }
.info-unit {
@ -178,4 +208,4 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
`}</style>
</div>
);
}
}

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 { formatNumber } from "~/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip"
interface DataItem {
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">
{/* Animated bar */}
<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}%` }}
>
<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}
</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>
<span className="text-white font-bold text-base">
{formatNumber(item.value)}

View File

@ -1,38 +1,6 @@
import { useState, useEffect } from "react";
import { DashboardLayout } from "./layout";
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 { Book, CheckCircle } from "lucide-react";
import { useEffect, useState } from "react";
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 {
Label,
PolarGrid,
@ -40,10 +8,21 @@ import {
RadialBar,
RadialBarChart,
} 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 { 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() {
const [dashboardData, setDashboardData] = useState<any | null>(null);
@ -51,35 +30,54 @@ export function DashboardHome() {
const [error, setError] = useState<string | null>(null);
// Chart and schematic data from select API
const [companyChartData, setCompanyChartData] = useState<
{ category: string; capacity: number; revenue: number; cost: number , costI : number,
capacityI : number,
revenueI : number }[]
{
category: string;
capacity: number;
revenue: number;
cost: number;
costI: number;
capacityI: number;
revenueI: number;
}[]
>([]);
const [totalIncreasedCapacity, setTotalIncreasedCapacity] = useState<number>(0);
const [date, setDate] = useStoredDate();
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 () => {
try {
setLoading(true);
setError(null);
// First authenticate if needed
const token = localStorage.getItem("auth_token");
if (!token) {
await apiService.login("inogen_admin", "123456");
}
// Fetch top cards data
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
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);
@ -112,6 +110,10 @@ export function DashboardHome() {
"sum(pre_project_income)",
"sum(increased_income_after_innovation)",
],
Conditions: [
["start_date", ">=", date.start || null, "and"],
["start_date", "<=", date.end || null],
],
GroupBy: ["related_company"],
};
@ -130,31 +132,49 @@ export function DashboardHome() {
let incCapacityTotal = 0;
const chartRows = rows.map((r) => {
const rel = r?.related_company ?? "-";
const preFee = Number(r?.pre_innovation_fee_sum ?? 0) > 0 ? r?.pre_innovation_fee_sum : 0;
const costRed = 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;
const preFee =
Number(r?.pre_innovation_fee_sum ?? 0) >= 0
? r?.pre_innovation_fee_sum
: 0;
const costRed =
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;
const capacityPct = preCap > 0 ? (incCap / preCap) * 100 : 0;
const revenuePct = preInc > 0 ? (incInc / preInc) * 100 : 0;
const costPct = preFee > 0 ? (costRed / preFee) * 100 : 0;
const capacityPct = preCap >= 0 ? (incCap / preCap) * 100 : 0;
const revenuePct = preInc >= 0 ? (incInc / preInc) * 100 : 0;
const costPct = preFee >= 0 ? (costRed / preFee) * 100 : 0;
return {
category: rel,
capacity: isFinite(capacityPct) ? capacityPct : 0,
revenue: isFinite(revenuePct) ? revenuePct : 0,
cost: isFinite(costPct) ? costPct : 0,
costI : costRed,
capacityI : incCap,
revenueI : incInc
costI: costRed,
capacityI: incCap,
revenueI: incInc,
};
});
setCompanyChartData(chartRows);
setTotalIncreasedCapacity(incCapacityTotal);
// setTotalIncreasedCapacity(incCapacityTotal);
} catch (error) {
console.error("Error fetching dashboard data:", error);
const errorMessage =
@ -167,25 +187,24 @@ export function DashboardHome() {
};
// RadialBarChart data for ideas visualization
const getIdeasChartData = () => {
if (!dashboardData?.topData)
return [{ browser: "safari", visitors: 0, fill: "var(--color-safari)" }];
// const getIdeasChartData = () => {
// if (!dashboardData?.topData)
// return [{ browser: "safari", visitors: 0, fill: "var(--color-safari)" }];
const registered = parseFloat(
dashboardData.topData.registered_innovation_technology_idea || "0",
);
const ongoing = parseFloat(
dashboardData.topData.ongoing_innovation_technology_ideas || "0",
);
const percentage =
registered > 0 ? Math.round((ongoing / registered) * 100) : 0;
// const registered = parseFloat(
// dashboardData.topData.registered_innovation_technology_idea || "0"
// );
// const ongoing = parseFloat(
// dashboardData.topData.ongoing_innovation_technology_ideas || "0"
// );
// const percentage = registered > 0 ? (ongoing / registered) * 100 : 0;
return [
{ browser: "safari", visitors: percentage, fill: "var(--color-safari)" },
];
};
// return [
// { browser: "safari", visitors: percentage, fill: "var(--color-safari)" },
// ];
// };
const chartData = getIdeasChartData();
// const chartData = getIdeasChartData();
const chartConfig = {
visitors: {
@ -230,11 +249,11 @@ export function DashboardHome() {
style={{ height: `${Math.random() * 80 + 20}%` }}
></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}%` }}
></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}%` }}
></div>
</div>
@ -251,7 +270,7 @@ export function DashboardHome() {
if (loading) {
return (
<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 */}
<div className="flex justify-between gap-6 [&>*]:w-full col-span-3">
<SkeletonCard />
@ -312,7 +331,7 @@ export function DashboardHome() {
return (
<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 */}
<div className="flex justify-between gap-6 [&>*]:w-full col-span-3">
{/* Ideas Card */}
@ -329,23 +348,22 @@ export function DashboardHome() {
visitors:
parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea || "0",
?.registered_innovation_technology_idea || "0"
) > 0
? Math.round(
(parseFloat(
dashboardData.topData
?.ongoing_innovation_technology_ideas ||
"0",
?.ongoing_innovation_technology_ideas || "0"
) /
parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea ||
"1",
"1"
)) *
100,
100
)
: 0,
fill: "green",
fill: "var(--color-green)",
},
]}
startAngle={90}
@ -353,19 +371,18 @@ export function DashboardHome() {
90 +
((parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea || "0",
?.registered_innovation_technology_idea || "0"
) > 0
? Math.round(
(parseFloat(
dashboardData.topData
?.ongoing_innovation_technology_ideas || "0",
?.ongoing_innovation_technology_ideas || "0"
) /
parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea ||
"1",
?.registered_innovation_technology_idea || "1"
)) *
100,
100
)
: 0) /
100) *
@ -378,14 +395,10 @@ export function DashboardHome() {
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-red-400 last:fill-[#111628]"
className="first:fill-pr-red last:fill-[#24273A]"
polarRadius={[38, 31]}
/>
<RadialBar
dataKey="visitors"
background
cornerRadius={5}
/>
<RadialBar dataKey="visitors" background cornerRadius={5} />
<PolarRadiusAxis
tick={false}
tickLine={false}
@ -411,22 +424,22 @@ export function DashboardHome() {
parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea ||
"0",
"0"
) > 0
? Math.round(
(parseFloat(
dashboardData.topData
?.ongoing_innovation_technology_ideas ||
"0",
"0"
) /
parseFloat(
dashboardData.topData
?.registered_innovation_technology_idea ||
"1",
"1"
)) *
100,
100
)
: 0,
: 0
)}
</tspan>
</text>
@ -443,14 +456,14 @@ export function DashboardHome() {
<div className="font-light text-sm">ثبت شده :</div>
{formatNumber(
dashboardData.topData
?.registered_innovation_technology_idea || "0",
?.registered_innovation_technology_idea || "0"
)}
</span>
<span className="flex items-center gap-1 font-bold text-base">
<div className="font-light text-sm">در حال اجرا :</div>
{formatNumber(
dashboardData.topData
?.ongoing_innovation_technology_ideas || "0",
?.ongoing_innovation_technology_ideas || "0"
)}
</span>
</div>
@ -460,145 +473,162 @@ export function DashboardHome() {
{/* Revenue Card */}
<MetricCard
title="افزایش درآمد مبتنی بر فناوری و نوآوری"
value={dashboardData.topData?.technology_innovation_based_revenue_growth || "0"}
percentValue={Math.round(dashboardData.topData?.technology_innovation_based_revenue_growth_percent) || "0"}
value={
dashboardData.topData?.technology_innovation_based_revenue_growth?.replaceAll(
",",
""
) || "0"
}
percentValue={
dashboardData.topData
?.technology_innovation_based_revenue_growth_percent
}
percentLabel="درصد به کل درآمد"
/>
{/* Cost Reduction Card */}
<MetricCard
title="کاهش هزینه ها مبتنی بر فناوری و نوآوری"
value={Math.round(parseFloat(dashboardData.topData?.technology_innovation_based_cost_reduction?.replace(/,/g, "") || "0") / 1000000)}
percentValue={Math.round(dashboardData.topData?.technology_innovation_based_cost_reduction_percent) || "0"}
value={Math.round(
parseFloat(
dashboardData.topData?.technology_innovation_based_cost_reduction?.replace(
/,/g,
""
) || "0"
)
)}
percentValue={
dashboardData.topData
?.technology_innovation_based_cost_reduction_percent || "0"
}
percentLabel="درصد به کل هزینه"
/>
{/* Budget Ratio Card */}
<BaseCard title="نسبت تحقق بودجه فناوی و نوآوری">
<div className="flex items-center gap-2 justify-center flex-row-reverse">
<ChartContainer
config={chartConfig}
className="aspect-square w-[6rem] h-auto"
<ChartContainer
config={chartConfig}
className="aspect-square w-[6rem] h-auto"
>
<RadialBarChart
data={[
{
browser: "budget",
visitors: parseFloat(
dashboardData.topData
?.innovation_budget_achievement_percent || "0"
),
fill: "var(--color-green)",
},
]}
startAngle={90}
endAngle={
90 +
(dashboardData.topData
?.innovation_budget_achievement_percent /
100) *
360
}
innerRadius={35}
outerRadius={55}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-pr-red last:fill-[#24273A]"
polarRadius={[38, 31]}
/>
<RadialBar dataKey="visitors" background cornerRadius={5} />
<PolarRadiusAxis
tick={false}
tickLine={false}
axisLine={false}
>
<RadialBarChart
data={[
{
browser: "budget",
visitors: parseFloat(
dashboardData.topData
?.innovation_budget_achievement_percent || "0",
),
fill: "green",
},
]}
startAngle={90}
endAngle={
90 +
(dashboardData.topData
?.innovation_budget_achievement_percent /
100) *
360
}
innerRadius={35}
outerRadius={55}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-red-400 last:fill-[#111628]"
polarRadius={[38, 31]}
/>
<RadialBar
dataKey="visitors"
background
cornerRadius={5}
/>
<PolarRadiusAxis
tick={false}
tickLine={false}
axisLine={false}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-lg font-bold"
>
%
{formatNumber(
Math.round(
dashboardData.topData
?.innovation_budget_achievement_percent ||
0,
),
)}
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
<div className="font-bold font-persian text-center">
<div className="flex flex-col justify-between items-center gap-2">
<span className="flex font-bold items-center text-base gap-1 mr-auto">
<div className="font-light text-sm">مصوب :</div>
{formatNumber(
Math.round(
parseFloat(
dashboardData.topData?.approved_innovation_budget_achievement_ratio?.replace(
/,/g,
"",
) || "0",
) / 1000000000,
),
)}
</span>
<span className="flex items-center gap-1 text-base font-bold mr-auto">
<div className="font-light text-sm">جذب شده :</div>
{formatNumber(
Math.round(
parseFloat(
dashboardData.topData?.allocated_innovation_budget_achievement_ratio?.replace(
/,/g,
"",
) || "0",
) / 1000000000,
),
)}
</span>
</div>
</div>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-lg font-bold"
>
%
{formatNumber(
Math.round(
dashboardData.topData
?.innovation_budget_achievement_percent ||
0
)
)}
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
<div className="font-bold font-persian text-center">
<div className="flex flex-col justify-between items-center gap-2">
<span className="flex font-bold items-center text-base gap-1 mr-auto">
<div className="font-light text-sm">مصوب :</div>
{formatNumber(
Math.round(
parseFloat(
dashboardData.topData?.approved_innovation_budget_achievement_ratio?.replace(
/,/g,
""
) || "0"
)
)
)}
</span>
<span className="flex items-center gap-1 text-base font-bold mr-auto">
<div className="font-light text-sm">جذب شده :</div>
{formatNumber(
Math.round(
parseFloat(
dashboardData.topData?.allocated_innovation_budget_achievement_ratio?.replace(
/,/g,
""
) || "0"
)
)
)}
</span>
</div>
</BaseCard>
</div>
</div>
</div>
</BaseCard>
</div>
{/* Main Content with 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)]"
>
<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>
<TabsList className="bg-transparent py-2 border m-6 border-gray-600">
<TabsTrigger value="canvas" className="">
<TabsList className="bg-transparent py-2 m-6 border-[1px] border-[#5F6284]">
<TabsTrigger value="canvas" className="cursor-pointer">
شماتیک
</TabsTrigger>
<TabsTrigger value="charts" className=" text-white font-light ">
<TabsTrigger
value="charts"
className=" text-white cursor-pointer font-light "
>
مقایسه ای
</TabsTrigger>
</TabsList>
@ -611,27 +641,48 @@ export function DashboardHome() {
<TabsContent value="canvas" className="w-ful h-full">
<div className="p-4 h-full w-full">
<D3ImageInfo
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> = {
// بسپاران: "/besparan.png",
// خوارزمی: "/khwarazmi.png",
// "فراورش 1": "/faravash1.png",
// "فراورش 2": "/faravash2.png",
// کیمیا: "/kimia.png",
// "آب نیرو": "/abniro.png",
// };
return {
id: item.category,
name: item.category,
imageUrl: imageMap[item.category] || "/placeholder.png",
cost: item?.costI || 0,
capacity: item?.capacityI || 0,
revenue: item?.revenueI || 0,
};
})
}
//پتروشیمی آپادانا
companies={companyChartData.map((item) => {
const imageMap: Record<string, string> = {
"واحد 100": "/abniro.png" ,
"واحد 200": "/besparan.png" ,
"واحد 300": "/khwarazmi.png" ,
"واحد 400": "/faravash1.png"
};
//پتروشیمی نوری
// companies={companyChartData.map((item) => {
// const imageMap: Record<string, string> = {
// "واحد 100": "/abniro.png" ,
// "واحد 200": "/besparan.png" ,
// "واحد 300": "/khwarazmi.png" ,
// "واحد 400": "/faravash1.png"
// };
return {
id: item.category,
name: item.category,
imageUrl: imageMap[item.category] || "/placeholder.png",
cost: item?.costI || 0,
capacity: item?.capacityI || 0,
revenue: item?.revenueI || 0,
};
})}
/>
</div>
</TabsContent>
@ -646,17 +697,10 @@ export function DashboardHome() {
<CardTitle className="text-white text-sm min-w-[100px]">
شدت فناوری
</CardTitle>
<p className="text-base text-left">
%
{formatNumber(
Math.round(
dashboardData.leftData?.technology_intensity || 0,
),
)}
</p>
<Progress
value={parseFloat(
dashboardData.leftData?.technology_intensity || "0",
dashboardData.leftData?.technology_intensity
)}
className="h-4 flex-1"
/>
@ -674,21 +718,21 @@ export function DashboardHome() {
{
label: "اجرا شده",
value: parseFloat(
dashboardData?.leftData?.executed_project || "0",
dashboardData?.leftData?.executed_project || "0"
),
color: "bg-pr-green",
},
{
label: "در حال اجرا",
value: parseFloat(
dashboardData?.leftData?.in_progress_project || "0",
dashboardData?.leftData?.in_progress_project || "0"
),
color: "bg-pr-blue",
},
{
label: "برنامه‌ریزی شده",
value: parseFloat(
dashboardData?.leftData?.planned_project || "0",
dashboardData?.leftData?.planned_project || "0"
),
color: "bg-pr-red",
},
@ -713,7 +757,7 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.printed_books_count || "0",
dashboardData.leftData?.printed_books_count || "0"
)}
</span>
</div>
@ -724,7 +768,7 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.registered_patents_count || "0",
dashboardData.leftData?.registered_patents_count || "0"
)}
</span>
</div>
@ -735,18 +779,18 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.published_reports_count || "0",
dashboardData.leftData?.published_reports_count || "0"
)}
</span>
</div>
<div className="flex items-center justify-center gap-4">
<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>
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.printed_articles_count || "0",
dashboardData.leftData?.printed_articles_count || "0"
)}
</span>
</div>
@ -770,7 +814,7 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.attended_conferences_count || "0",
dashboardData.leftData?.attended_conferences_count || "0"
)}
</span>
</div>
@ -781,7 +825,7 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.attended_events_count || "0",
dashboardData.leftData?.attended_events_count || "0"
)}
</span>
</div>
@ -792,18 +836,18 @@ export function DashboardHome() {
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.attended_exhibitions_count || "0",
dashboardData.leftData?.attended_exhibitions_count || "0"
)}
</span>
</div>
<div className="flex items-center justify-center gap-4">
<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>
</div>
<span className="text-base font-bold ">
{formatNumber(
dashboardData.leftData?.organized_events_count || "0",
dashboardData.leftData?.organized_events_count || "0"
)}
</span>
</div>
@ -811,9 +855,8 @@ export function DashboardHome() {
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
</div>
</DashboardLayout>
);
}

View File

@ -1,52 +1,403 @@
import { useEffect, useState } from "react";
import { useAuth } from "~/contexts/auth-context";
import { Link } from "react-router";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { saveAs } from "file-saver";
import jalaali from "jalaali-js";
import {
PanelLeft,
Settings,
User,
Calendar,
ChevronLeft,
FileChartColumnIncreasing,
Menu,
ChevronDown,
PanelLeft,
Server,
User,
} 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 { cn, EventBus, handleDataValue } from "~/lib/utils";
interface HeaderProps {
onToggleSidebar?: () => void;
className?: string;
title?: string;
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({
onToggleSidebar,
className,
title = "صفحه اول",
titleIcon,
}: HeaderProps) {
const { user } = useAuth();
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
const { jy } = jalaali.toJalaali(new Date());
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 () => {
try {
const getData = await apiService.post('/GenerateSsoCode')
const url = `http://localhost:3000/redirect/${getData.data}`;
const getData = await apiService.post("/GenerateSsoCode");
//بندر امام
// 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");
} catch (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 (
<header
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",
className,
className
)}
>
{/* Left Section */}
@ -65,8 +416,79 @@ export function Header({
{/* Page Title */}
<h1 className="text-xl flex items-center justify-center gap-4 font-bold text-white font-persian">
<PanelLeft /> {title}
{/* Right-side icon for current page */}
{titleIcon ? (
<div className="flex items-center gap-2 mr-4">
{React.createElement(titleIcon, { className: "w-5 h-5 " })}
</div>
) : (
<PanelLeft />
)}
{title.includes("-") ? (
<div className="flex row items-center gap-4">
<div className="flex items-center gap-1">
{title.split("-")[0]}
<ChevronLeft className="inline-block w-4 h-4" />
{title.split("-")[1]}
</div>
</div>
) : (
title
)}
</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>
{/* Right Section */}
@ -74,14 +496,29 @@ export function Header({
{/* User Menu */}
<div className="relative">
<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 && <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"
onClick={redirectHandler}>
{user?.id === 2041 && (
<button
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}
>
<Server className="h-4 w-4" />
ورود به میزکار مدیریت</button>
}
ورود به میزکار مدیریت
</button>
)}
<Button
variant="ghost"
@ -89,9 +526,6 @@ export function Header({
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
className="flex items-center gap-2 text-gray-300"
>
<div className="w-8 h-8 bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 rounded-full flex items-center justify-center">
<User className="h-4 w-4" />
</div>
<div className="hidden sm:block text-right">
<div className="text-sm font-medium font-persian">
{user?.name} {user?.family}
@ -100,9 +534,12 @@ export function Header({
{user?.username}
</div>
</div>
<ChevronDown className="h-3 w-3" />
<div className="w-8 h-8 bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 rounded-lg flex items-center justify-center">
<User className="h-4 w-4" />
</div>
</Button>
</div>
{/* Profile Dropdown */}
{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">
@ -114,7 +551,7 @@ export function Header({
{user?.email}
</div>
</div>
<div className="py-1">
{/* <div className="py-1">
<Link
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"
@ -122,16 +559,16 @@ export function Header({
>
<User className="h-4 w-4" />
پروفایل کاربری
</Link>
<Link
</Link>
<Link
to="/dashboard/settings"
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"
onClick={() => setIsProfileMenuOpen(false)}
>
<Settings className="h-4 w-4" />
تنظیمات
</Link>
</div>
</Link>
</div> */}
</div>
)}
</div>

View File

@ -1,9 +1,8 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
import { Sidebar } from "./sidebar";
import { StrategicAlignmentPopup } from "./strategic-alignment-popup";
import apiService from "~/lib/api";
interface DashboardLayoutProps {
children: React.ReactNode;
@ -18,7 +17,14 @@ export function DashboardLayout({
}: DashboardLayoutProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] = useState(false);
const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] =
useState(false);
const [currentTitle, setCurrentTitle] = useState<string | undefined>(
title ?? "صفحه اول"
);
const [currentTitleIcon, setCurrentTitleIcon] = useState<
React.ComponentType<{ className?: string }> | null | undefined
>(undefined);
const toggleSidebarCollapse = () => {
setIsSidebarCollapsed(!isSidebarCollapsed);
@ -28,8 +34,6 @@ export function DashboardLayout({
setIsMobileSidebarOpen(!isMobileSidebarOpen);
};
return (
<div
className="h-screen flex overflow-hidden bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)] relative overflow-x-hidden"
@ -53,15 +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",
isMobileSidebarOpen
? "translate-x-0"
: "translate-x-full lg:translate-x-0",
: "translate-x-full lg:translate-x-0"
)}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
onToggleCollapse={toggleSidebarCollapse}
className="h-full flex-shrink-0 relative z-10"
onStrategicAlignmentClick={() => setIsStrategicAlignmentPopupOpen(true)}
onStrategicAlignmentClick={() =>
setIsStrategicAlignmentPopupOpen(true)
}
onTitleChange={(info) => {
setCurrentTitle(info.title);
setCurrentTitleIcon(info.icon ?? null);
}}
/>
</div>
@ -71,22 +80,26 @@ export function DashboardLayout({
<Header
onToggleSidebar={toggleMobileSidebar}
className="flex-shrink-0"
title={title}
title={currentTitle}
titleIcon={currentTitleIcon}
/>
{/* Main content */}
<main
className={cn(
"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}
</div>
</main>
</div>
<StrategicAlignmentPopup open={isStrategicAlignmentPopupOpen} onOpenChange={setIsStrategicAlignmentPopupOpen} />
<StrategicAlignmentPopup
open={isStrategicAlignmentPopupOpen}
onOpenChange={setIsStrategicAlignmentPopupOpen}
/>
</div>
);
}

View File

@ -12,10 +12,10 @@ import {
Zap,
} from "lucide-react";
import moment from "moment-jalaali";
import { formatNumber } from "~/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Badge } from "~/components/ui/badge";
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";
@ -34,8 +34,10 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
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";
moment.loadPersian({ usePersianDigits: true });
@ -153,7 +155,7 @@ export function DigitalInnovationPage() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [date, setDate] = useStoredDate();
const [actualTotalCount, setActualTotalCount] = useState(0);
const [statsLoading, setStatsLoading] = useState(false);
const [rating, setRating] = useState<ListItem[]>([]);
@ -181,6 +183,8 @@ export function DigitalInnovationPage() {
// const [avarage, setAvarage] = useState<number>(0);
const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Selection handlers
const handleSelectAll = () => {
@ -211,7 +215,7 @@ export function DigitalInnovationPage() {
value: formatNumber(stats.reduceCosts.toFixed?.(1) ?? stats.reduceCosts),
description: "میلیون ریال کاهش یافته",
icon: <TrendingDown />,
color: "text-emerald-400",
color: "text-pr-green",
},
{
id: "bottleneck-removal",
@ -219,7 +223,7 @@ export function DigitalInnovationPage() {
value: formatNumber(stats.increasedRevenue),
description: "میلیون ریال افزایش یافته",
icon: <TrendingUp />,
color: "text-emerald-400",
color: "text-pr-green",
},
{
@ -230,7 +234,7 @@ export function DigitalInnovationPage() {
),
description: "هزار تن صرفه جوریی شده",
icon: <Database />,
color: "text-emerald-400",
color: "text-pr-green",
},
{
id: "frequent-failures-reduction",
@ -241,7 +245,7 @@ export function DigitalInnovationPage() {
),
description: "مگاوات کاهش یافته",
icon: <Zap />,
color: "text-emerald-400",
color: "text-pr-green",
},
];
@ -280,7 +284,11 @@ export function DigitalInnovationPage() {
"reduce_costs_percent",
],
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 },
});
@ -293,16 +301,16 @@ export function DigitalInnovationPage() {
if (reset) {
setProjects(parsedData);
// calculateAverage(parsedData);
setTotalCount(parsedData.length);
// setTotalCount(parsedData.length);
} else {
setProjects((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length);
// setTotalCount((prev) => prev + parsedData.length);
}
setHasMore(parsedData.length === pageSize);
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -310,14 +318,14 @@ export function DigitalInnovationPage() {
console.error("Error parsing project data:", parseError);
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -325,7 +333,7 @@ export function DigitalInnovationPage() {
toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -334,7 +342,7 @@ export function DigitalInnovationPage() {
toast.error("خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
} finally {
@ -346,45 +354,75 @@ export function DigitalInnovationPage() {
};
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
setCurrentPage((prev) => prev + 1);
}
}, [loadingMore, hasMore, loading]);
}, [hasMore, loading, loadingMore]);
useEffect(() => {
fetchTable(true);
fetchTotalCount();
fetchStats();
}, [sortConfig]);
if (date?.start && date?.end) {
fetchTable(true);
fetchTotalCount();
fetchStats();
}
}, [sortConfig, date]);
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);
}
}, [currentPage]);
// Infinite scroll observer with debouncing
useEffect(() => {
const scrollContainer = document.querySelector(".overflow-auto");
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return;
if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) {
loadMore();
// Clear previous timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Debounce scroll events
scrollTimeoutRef.current = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Trigger load more when scrolled to 95% of the container
if (scrollPercentage >= 0.95) {
loadMore();
}
}, 150);
};
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
scrollContainer.addEventListener("scroll", handleScroll, {
passive: true,
});
}
return () => {
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [loadMore, hasMore, loadingMore]);
@ -395,19 +433,23 @@ export function DigitalInnovationPage() {
direction:
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
}));
fetchTotalCount();
fetchTotalCount(date?.start, date?.end);
fetchStats();
setCurrentPage(1);
setProjects([]);
setHasMore(true);
};
const fetchTotalCount = async () => {
const fetchTotalCount = async (startDate?: string, endDate?: string) => {
try {
const response = await apiService.select({
ProcessName: "project",
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) {
@ -434,17 +476,18 @@ export function DigitalInnovationPage() {
try {
setStatsLoading(true);
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;
// console.log("*-*-*-*" +payload);
// if (typeof payload === "string") {
// try {
// payload = JSON.parse(payload).innovation_digital_function;
// } catch {}
// }
@ -454,10 +497,10 @@ export function DigitalInnovationPage() {
try {
// مرحله اول: data رو از string به object تبدیل کن
const parsedData = JSON.parse(raw.data);
// مرحله دوم: innovation_digital_function رو که خودش string هست parse کن
const arr = JSON.parse(parsedData.innovation_digital_function);
// مرحله سوم: اولین خانه آرایه رو بردار
if (Array.isArray(arr) && arr.length > 0) {
payload = arr[0];
@ -467,8 +510,6 @@ export function DigitalInnovationPage() {
}
}
const parseNum = (v: unknown): number => {
if (v == null) return 0;
if (typeof v === "number") return v;
@ -516,33 +557,33 @@ export function DigitalInnovationPage() {
// fetchStats();
// };
const renderProgress = useMemo(() => {
const total = 10;
for (let i = 0; i < rating.length; i++) {
const currentElm = rating[i];
currentElm.house = [];
const greenBoxes = Math.floor((total * currentElm.development) / 100);
const partialPercent =
(total * currentElm.development) / 100 - greenBoxes;
for (let j = 0; j < greenBoxes; j++) {
currentElm.house.push({
index: j,
color: "!bg-emerald-400",
});
}
if (partialPercent != 0 && greenBoxes != 10)
currentElm.house.push({
index: greenBoxes + 1,
style: `linear-gradient(
to right,
oklch(76.5% 0.177 163.223) 0%,
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) 100%
)`,
});
}
}, [rating]);
// const renderProgress = useMemo(() => {
// const total = 10;
// for (let i = 0; i < rating.length; i++) {
// const currentElm = rating[i];
// currentElm.house = [];
// const greenBoxes = Math.floor((total * currentElm.development) / 100);
// const partialPercent =
// (total * currentElm.development) / 100 - greenBoxes;
// for (let j = 0; j < greenBoxes; j++) {
// currentElm.house.push({
// index: j,
// color: "!bg-emerald-400",
// });
// }
// if (partialPercent != 0 && greenBoxes != 10)
// currentElm.house.push({
// index: greenBoxes + 1,
// style: `linear-gradient(
// to right,
// oklch(76.5% 0.177 163.223) 0%,
// 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) 100%
// )`,
// });
// }
// }, [rating]);
const statusColor = (status: projectStatus): any => {
let el = null;
@ -586,14 +627,14 @@ export function DigitalInnovationPage() {
variant="ghost"
size="sm"
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>
);
case "amount_currency_reduction":
return (
<span className="font-medium text-emerald-400">
<span className="font-medium text-pr-green">
{formatCurrency(String(value))}
</span>
);
@ -604,7 +645,9 @@ export function DigitalInnovationPage() {
</Badge>
);
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":
return (
<div className="flex items-center gap-1">
@ -639,7 +682,7 @@ export function DigitalInnovationPage() {
return (
<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 */}
<div className="flex flex-col gap-6 w-full mb-0">
<div className="space-y-6 w-full">
@ -696,7 +739,7 @@ export function DigitalInnovationPage() {
</div>
<div className="flex items-center justify-center flex-col p-2 pb-4">
<p
className={`text-3xl font-bold ${card.color} mb-1`}
className={`text-3xl font-bold ${card.color} mb-1`}
>
{card.value}
</p>
@ -712,50 +755,49 @@ export function DigitalInnovationPage() {
</div>
{/* 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 > */}
<CustomBarChart
title="تاثیرات نوآوری دیجیتال به صورت درصد مقایسه ای"
loading={statsLoading}
height="100%"
// height="100%"
data={[
{
label: DigitalCardLabel.decreasCost,
value: stats.reduceCostsPercent || 0,
color: "bg-emerald-400",
color: "bg-pr-green",
labelColor: "text-white",
},
{
label: DigitalCardLabel.increaseRevenue,
value: stats.increasedRevenuePercent || 0,
color: "bg-emerald-400",
color: "bg-pr-green",
labelColor: "text-white",
},
{
label: DigitalCardLabel.performance,
value: stats.resourceProductivityPercent || 0,
color: "bg-emerald-400",
color: "bg-pr-green",
labelColor: "text-white",
},
{
label: DigitalCardLabel.decreaseEnergy,
value: stats.reduceEnergyConsumptionPercent || 0,
color: "bg-emerald-400",
color: "bg-pr-green",
labelColor: "text-white",
},
]}
barHeight="h-5"
showAxisLabels={true}
/>
{/* </CardContent> */}
</Card>
</BaseCard>
</div>
{/* 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">
<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>
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
@ -934,13 +976,13 @@ export function DigitalInnovationPage() {
{/* Project Details Dialog */}
<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>
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
شرح پروژه
</DialogTitle>
</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">
<span className="title text-lg font-bold">
{dialogInfo?.title}
@ -992,7 +1034,7 @@ export function DigitalInnovationPage() {
</div>
<div className="digitalAbilityDevelopment flex flex-col gap-10 border-l-2 border-l-gray-600 px-5">
<div className="flex flex-col gap-4">
<span className="text-md font-bold">
<span className="text-lg font-bold">
توسعه قابلیت های دیجیتال:{" "}
</span>
<div className="flex flex-col gap-2">
@ -1055,10 +1097,10 @@ export function DigitalInnovationPage() {
</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="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>

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import moment from "moment-jalaali";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Badge } from "~/components/ui/badge";
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";
@ -33,8 +34,10 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
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";
moment.loadPersian({ usePersianDigits: true });
@ -49,6 +52,11 @@ interface ProcessInnovationData {
amount_currency_reduction: string;
Reduce_rate_failure: string;
observer: string;
// optional detailed fields returned by API
project_description?: string;
start_date?: string;
done_date?: string;
approved_budget?: string;
}
interface ProjectStats {
@ -59,9 +67,11 @@ interface ProjectStats {
percent_reduction_value_currency: string;
percent_sum_stopping_production: string;
percent_throat_removal: string;
percent_operating_cost_before_innovation: string;
sum_reducing_breakdowns: number;
sum_reduction_value_currency: number;
sum_stopping_production: number;
sum_operating_cost_reduction: number;
}
interface SortConfig {
@ -86,9 +96,11 @@ interface InnovationStats {
currencyReductionSum: number; // مجموع کاهش ارز بری (میلیون ریال)
frequentFailuresReductionSum: number; // مجموع کاهش خرابی های پرتکرار
percentProductionStops: number | string; // درصد مقایسه‌ای جلوگیری از توقفات تولید
reductionCostOprationSum: number; // مجموع کاهش هزینه عملیاتی
percentBottleneckRemoval: number | string; // درصد مقایسه‌ای رفع گلوگاه
percentCurrencyReduction: number | string; // درصد مقایسه‌ای کاهش ارز بری
percentFailuresReduction: number | string; // درصد مقایسه‌ای کاهش خرابی‌های پرتکرار
percentOperatingCostBeforeInnovation: number | string; // درصد مقایسه‌ای کاهش هزینه عملیاتی
}
const columns = [
@ -117,13 +129,14 @@ export function ProcessInnovationPage() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [date, setDate] = useStoredDate();
const [actualTotalCount, setActualTotalCount] = useState(0);
const [statsLoading, setStatsLoading] = useState(false);
const [stats, setStats] = useState<InnovationStats>({
totalProjects: 0,
averageScore: 0,
productionStopsPreventionSum: 0,
reductionCostOprationSum: 0,
bottleneckRemovalCount: 0,
currencyReductionSum: 0,
frequentFailuresReductionSum: 0,
@ -131,6 +144,7 @@ export function ProcessInnovationPage() {
percentBottleneckRemoval: 0,
percentCurrencyReduction: 0,
percentFailuresReduction: 0,
percentOperatingCostBeforeInnovation: 0,
});
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "start_date",
@ -146,58 +160,60 @@ export function ProcessInnovationPage() {
const [stateCard, setStateCard] = useState({
productionstopsprevention: {
id: "productionstopsprevention",
title: "جلوگیری از توقفات تولید",
title: "توقفات تولید",
value: formatNumber(
stats.productionStopsPreventionSum.toFixed?.(1) ??
stats.productionStopsPreventionSum
),
description: "تن افزایش یافته",
icon: <CirclePause />,
color: "text-emerald-400",
icon: CirclePause,
color: "text-pr-green",
},
bottleneckremoval: {
id: "bottleneckremoval",
title: "رفع گلوگاه",
title: "گلوگاه ها",
value: formatNumber(stats.bottleneckRemovalCount),
description: "تعداد رفع گلوگاه",
icon: <Funnel />,
color: "text-emerald-400",
icon: Funnel,
color: "text-pr-green",
},
currencyreduction: {
id: "currencyreduction",
title: "کاهش ارز بری",
title: "ارز بری",
value: formatNumber(
stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum
),
description: "دلار کاهش یافته",
icon: <DollarSign />,
color: "text-emerald-400",
icon: DollarSign,
color: "text-pr-green",
},
decreaseCurrencyOperation: {
id: "decreaseCurrencyOperation",
title: "هزینه های عملیاتی",
value: formatNumber(
stats.reductionCostOprationSum.toFixed?.(0) ??
stats.reductionCostOprationSum
),
description: "میلیون ریال کاهش یافته",
icon: DollarSign,
color: "text-pr-green",
},
frequentfailuresreduction: {
id: "frequentfailuresreduction",
title: "کاهش خرابی های پرتکرار",
title: "خرابی های پرتکرار",
value: formatNumber(
stats.frequentFailuresReductionSum.toFixed?.(1) ??
stats.frequentFailuresReductionSum
),
description: "مجموع درصد کاهش خرابی",
icon: <Wrench />,
color: "text-emerald-400",
description: "خرابی پر تکرار کاهش یافته",
icon: Wrench,
color: "text-pr-green",
},
});
const observerRef = useRef<HTMLDivElement>(null);
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 newSelected = new Set(selectedProjects);
if (newSelected.has(projectNo)) {
@ -250,11 +266,14 @@ export function ProcessInnovationPage() {
"observer",
],
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 },
});
console.log(JSON.parse(response.data));
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
@ -263,16 +282,16 @@ export function ProcessInnovationPage() {
if (Array.isArray(parsedData)) {
if (reset) {
setProjects(parsedData);
setTotalCount(parsedData.length);
// setTotalCount(parsedData.length);
} else {
setProjects((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length);
// setTotalCount((prev) => prev + parsedData.length);
}
setHasMore(parsedData.length === pageSize);
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -280,14 +299,14 @@ export function ProcessInnovationPage() {
console.error("Error parsing project data:", parseError);
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -295,7 +314,7 @@ export function ProcessInnovationPage() {
toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
}
@ -304,7 +323,7 @@ export function ProcessInnovationPage() {
toast.error("خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
// setTotalCount(0);
}
setHasMore(false);
} finally {
@ -315,19 +334,33 @@ export function ProcessInnovationPage() {
};
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
if (hasMore && !loading) {
setCurrentPage((prev) => prev + 1);
}
}, [loadingMore, hasMore, loading]);
}, [hasMore, loading]);
useEffect(() => {
fetchProjects(true);
fetchTotalCount();
}, [sortConfig]);
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
useEffect(() => {
fetchStats();
}, [selectedProjects]);
if (date?.start && date?.end) {
fetchProjects(true);
fetchTotalCount();
}
}, [sortConfig, date]);
useEffect(() => {
if (date?.start && date?.end) fetchStats();
}, [selectedProjects, date]);
useEffect(() => {
if (currentPage > 1) {
@ -339,12 +372,12 @@ export function ProcessInnovationPage() {
const scrollContainer = document.querySelector(".overflow-auto");
const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return;
if (!scrollContainer || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) {
if (scrollPercentage == 1) {
loadMore();
}
};
@ -377,7 +410,11 @@ export function ProcessInnovationPage() {
const response = await apiService.select({
ProcessName: "project",
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) {
@ -411,6 +448,8 @@ export function ProcessInnovationPage() {
selectedProjects.size > 0
? Array.from(selectedProjects).join(" , ")
: "",
start_date: date?.start || null,
end_date: date?.end || null,
},
});
@ -441,10 +480,13 @@ export function ProcessInnovationPage() {
totalProjects: parseNum(stats?.count_innovation_process_projects),
averageScore: parseFloat(data[0].average_project_score),
productionStopsPreventionSum: parseNum(stats?.sum_stopping_production),
reductionCostOprationSum: parseNum(stats?.sum_operating_cost_reduction),
bottleneckRemovalCount: parseNum(stats?.count_throat_removal),
currencyReductionSum: parseNum(stats?.sum_reduction_value_currency),
frequentFailuresReductionSum: parseNum(stats?.sum_reducing_breakdowns),
percentProductionStops: stats?.percent_sum_stopping_production,
percentOperatingCostBeforeInnovation:
stats?.percent_operating_cost_before_innovation,
percentBottleneckRemoval: stats?.percent_throat_removal,
percentCurrencyReduction: stats?.percent_reduction_value_currency,
percentFailuresReduction: stats?.percent_reducing_breakdowns,
@ -467,6 +509,10 @@ export function ProcessInnovationPage() {
...prev.currencyreduction,
value: formatNumber(normalized.currencyReductionSum),
},
decreaseCurrencyOperation: {
...prev.decreaseCurrencyOperation,
value: formatNumber(normalized.reductionCostOprationSum),
},
}));
setStats(normalized);
} catch (error) {
@ -519,7 +565,7 @@ export function ProcessInnovationPage() {
<Checkbox
checked={selectedProjects.has(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":
@ -528,31 +574,35 @@ export function ProcessInnovationPage() {
variant="ghost"
size="sm"
onClick={() => handleProjectDetails(item)}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto"
className="text-pr-green underline-offset-4 underline font-normal p-2 h-auto"
>
جزئیات بیشتر
</Button>
);
case "amount_currency_reduction":
return (
<span className="font-medium text-emerald-400">
<span className="font-medium text-pr-green">
{formatCurrency(String(value))}
</span>
);
case "project_no":
return (
<Badge variant="outline" className="font-mono">
<Badge variant="outline" className="font-normal text-sm">
{String(value)}
</Badge>
);
case "title":
return <span className="font-medium text-white">{String(value)}</span>;
return (
<span className="font-normal text-sm text-white">
{String(value)}
</span>
);
case "project_status":
return (
<div className="flex items-center gap-1">
<Badge
variant={statusColor(value)}
className="font-medium border-2 p-0 block w-2 h-2 rounded-full"
variant={statusColor(value as projectStatus)}
className="font-normal text-base border-2 p-0 block w-2 h-2 rounded-full"
style={{
border: "none",
}}
@ -562,7 +612,10 @@ export function ProcessInnovationPage() {
);
case "project_rating":
return (
<Badge variant="outline" className="text-lg text-center border-none">
<Badge
variant="outline"
className="text-base font-semibold text-center border-none"
>
{formatNumber(String(value))}
</Badge>
);
@ -581,122 +634,201 @@ export function ProcessInnovationPage() {
return (
<DashboardLayout title="نوآوری در فرآیند">
<div className="p-6 space-y-4">
<div className="flex flex-col gap-4">
{/* Stats Cards */}
<div className="flex gap-6">
<div className="space-y-6 w-full">
<div className="flex gap-4">
<div className="space-y-4 w-full">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
{loading || statsLoading
? // Loading skeleton for stats cards - matching new design
Array.from({ length: 4 }).map((_, index) => (
<Card
<div className="h-full">
{loading || statsLoading ? (
// Skeleton cards
<div className="flex flex-wrap justify-between gap-3">
{Array.from({ length: 6 }).map((_, index) => (
<BaseCard
key={`skeleton-${index}`}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl overflow-hidden"
className="rounded-2xl overflow-hidden w-full sm:w-[48%] md:w-[30%]"
>
<CardContent className="p-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="h-6 bg-gray-600 rounded animate-pulse"
style={{ width: "60%" }}
/>
<div className="p-3 bg-emerald-500/20 rounded-full w-fit">
<div className="w-6 h-6 bg-gray-600 rounded animate-pulse" />
</div>
</div>
<div className="flex items-center justify-center flex-col p-1">
<div
className="h-8 bg-gray-600 rounded mb-1 animate-pulse"
style={{ width: "40%" }}
/>
<div
className="h-4 bg-gray-600 rounded animate-pulse"
style={{ width: "80%" }}
/>
<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="h-6 bg-gray-600 rounded animate-pulse"
style={{ width: "60%" }}
/>
<div className="p-3 rounded-full w-fit">
<div className="w-6 h-6 bg-gray-600 rounded animate-pulse" />
</div>
</div>
</CardContent>
</Card>
))
: Object.entries(stateCard).map(([key, card]) => (
<Card
key={card.id}
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"
>
<CardContent className="p-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">
<h3 className="text-lg font-bold text-white font-persian">
{card.title}
</h3>
<div
className={`p-3 gird placeitems-center rounded-full w-fit `}
>
{card.icon}
</div>
</div>
<div className="flex items-center justify-center flex-col p-1">
<p
className={`text-3xl font-bold ${card.color} mb-1`}
>
{card.value}
</p>
<p className="text-sm text-gray-300 font-persian">
{card.description}
</p>
</div>
<div className="flex items-center justify-center flex-col p-1">
<div
className="h-8 bg-gray-600 rounded mb-1 animate-pulse"
style={{ width: "40%" }}
/>
<div
className="h-4 bg-gray-600 rounded animate-pulse"
style={{ width: "80%" }}
/>
</div>
</CardContent>
</Card>
</div>
</BaseCard>
))}
</div>
) : (
<div className="flex flex-col h-full gap-5">
<div className="flex flex-row gap-4 h-full">
<BaseCard
key={stateCard.productionstopsprevention.id}
title={stateCard.productionstopsprevention.title}
className="border-gray-700/50 w-full"
icon={stateCard.productionstopsprevention.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.productionstopsprevention.value}
</p>
<div className="text-[11px] text-[#ACACAC] font-light font-persian">
{stateCard.productionstopsprevention.description}
</div>
</div>
</div>
</div>
</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>
{/* Process Impacts Chart */}
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl w-full overflow-hidden">
<CardContent>
<CustomBarChart
title="تاثیرات فرآیندی به صورت درصد مقایسه ای"
loading={statsLoading}
data={[
{
label: "کاهش توقفات تولید",
value: stats.percentProductionStops || 0,
color: "bg-emerald-400",
labelColor: "text-white",
},
{
label: "رفع گلوگاه تولید",
value: stats.percentBottleneckRemoval || 0,
color: "bg-emerald-400",
labelColor: "text-white",
},
{
label: "کاهش ارز بری",
value: stats.percentCurrencyReduction || 0,
color: "bg-emerald-400",
labelColor: "text-white",
},
{
label: "کاهش خرابی پر تکرار",
value: stats.percentFailuresReduction || 0,
color: "bg-emerald-400",
labelColor: "text-white",
},
]}
barHeight="h-5"
showAxisLabels={true}
/>
</CardContent>
</Card>
{/* نمودار با الگوریتم 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
title="تاثیرات فرآیندی به صورت درصد مقایسه ای"
loading={statsLoading}
data={[
{
label: "توقفات تولید",
value: Number(stats.percentProductionStops) || 0,
labelColor: "text-white",
},
{
label: "رفع گلوگاه تولید",
value: Number(stats.percentBottleneckRemoval) || 0,
labelColor: "text-white",
},
{
label: "ارز بری",
value: Number(stats.percentCurrencyReduction) || 0,
labelColor: "text-white",
},
{
label: "خرابی پر تکرار",
value: Number(stats.percentFailuresReduction) || 0,
labelColor: "text-white",
},
{
label: "هزینه های عملیاتی",
value:
Number(stats.percentOperatingCostBeforeInnovation) || 0,
labelColor: "text-white",
},
]}
barHeight="h-5"
showAxisLabels={true}
/>
</BaseCard>
</div>
{/* Data Table */}
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<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>
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
@ -707,14 +839,7 @@ export function ProcessInnovationPage() {
>
{column.key === "select" ? (
<div className="flex items-center justify-center">
<Checkbox
checked={
selectedProjects.size === projects.length &&
projects.length > 0
}
onCheckedChange={handleSelectAll}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
<span></span>
</div>
) : column.sortable ? (
<button
@ -750,7 +875,7 @@ export function ProcessInnovationPage() {
{columns.map((column) => (
<TableCell
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="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
@ -783,7 +908,7 @@ export function ProcessInnovationPage() {
{columns.map((column) => (
<TableCell
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)}
</TableCell>
@ -800,7 +925,7 @@ export function ProcessInnovationPage() {
{loadingMore && (
<div className="flex items-center justify-center py-1">
<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>
</div>
</div>
@ -810,7 +935,7 @@ export function ProcessInnovationPage() {
{/* Footer */}
<div className="p-2 px-4 bg-gray-700/50">
<div className="p-2 px-4 bg-[#3F415A]">
<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-base text-gray-401 mb-1">
@ -841,15 +966,17 @@ export function ProcessInnovationPage() {
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-4xl max-h-[80vh] overflow-y-auto">
<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 font-semibold text-sm text-right">
شرح پروژه
</DialogTitle>
</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 */}
<div className="flex-[4] border-l-2 border-gray-600">
<h2 className="font-bold">{selectedProjectDetails?.title}</h2>
<p className="text-gray-300 font-persian px-2 mt-2">
<h2 className="font-bold text-base">
{selectedProjectDetails?.title}
</h2>
<p className="text-white font-normal text-base font-persian px-2 mt-2">
{selectedProjectDetails?.project_description || "-"}
</p>
</div>
@ -859,11 +986,11 @@ export function ProcessInnovationPage() {
<div className="font-bold text-right ">جزئیات پروژه</div>
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
<Building2 className="h-4 text-green-500" />
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<Building2 className="h-4 text-pr-green text-sm font-light" />
زمان شروع:
</h4>
<span className="text-white font-bold font-persian">
<span className="text-white font-normal text-base font-persian">
{selectedProjectDetails?.start_date
? moment(
selectedProjectDetails?.start_date,
@ -874,11 +1001,11 @@ export function ProcessInnovationPage() {
</div>
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
<PickaxeIcon className="h-4 text-green-500" />
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<PickaxeIcon className="h-4 text-pr-green text-sm font-light" />
زمان پایان:
</h4>
<span className="text-white font-bold font-persian">
<span className="text-white font-normal text-base font-persian">
{selectedProjectDetails?.done_date
? moment(
selectedProjectDetails?.done_date,
@ -889,27 +1016,29 @@ export function ProcessInnovationPage() {
</div>
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
<UsersIcon className="h-4 text-green-500" />
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<UsersIcon className="h-4 text-pr-green text-sm font-light" />
هزینه برآورد شده:
</h4>
<span className="text-white font-bold font-persian">
{formatNumber(
Number(
selectedProjectDetails?.approved_budget.replaceAll(
",",
""
<span className="text-white font-normal text-base font-persian">
{selectedProjectDetails?.approved_budget
? formatNumber(
Number(
selectedProjectDetails.approved_budget.replaceAll(
",",
""
)
)
)
)
) || "-"}
: "-"}
</span>
</div>
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
<UserIcon className="h-4 text-green-500" />
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
<UserIcon className="h-4 text-pr-green text-sm font-light" />
نفر مرتبط:
</h4>
<span className="text-white font-bold font-persian">
<span className="text-white font-normal text-base font-persian">
{selectedProjectDetails?.observer || "-"}
</span>
</div>

View File

@ -1,19 +1,28 @@
import { saveAs } from "file-saver";
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import XLSX from "xlsx-js-style";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { useStoredDate } from "~/hooks/useStoredDate";
import apiService from "~/lib/api";
import { formatCurrency } from "~/lib/utils";
import { formatNumber } from "~/lib/utils";
import {
EventBus,
formatCurrency,
formatNumber,
handleDataValue,
} from "~/lib/utils";
import type { CalendarDate } from "~/types/util.type";
import { DashboardLayout } from "../layout";
interface ProjectData {
@ -53,51 +62,51 @@ type ColumnDef = {
};
const columns: ColumnDef[] = [
{ key: "title", label: "عنوان پروژه", sortable: true, width: "200px" },
{ key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
{
key: "importance_project",
label: "میزان اهمیت",
sortable: true,
width: "150px",
width: "160px",
},
{
key: "strategic_theme",
label: "مضمون راهبردی",
sortable: true,
width: "160px",
width: "200px",
},
{
key: "value_technology_and_innovation",
label: "ارزش فناوری و نوآوری",
sortable: true,
width: "200px",
width: "220px",
},
{
key: "type_of_innovation",
label: "انواع نوآوری",
sortable: true,
width: "140px",
width: "160px",
},
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" },
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "140px" },
{
key: "person_executing",
label: "مسئول اجرا",
sortable: true,
width: "140px",
width: "180px",
},
{
key: "excellent_observer",
label: "ناطر عالی",
sortable: true,
width: "140px",
width: "180px",
},
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
{ key: "moderator", label: "مجری", sortable: true, width: "140px" },
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "180px" },
{ key: "moderator", label: "مجری", sortable: true, width: "180px" },
{
key: "executive_phase",
label: "فاز اجرایی",
sortable: true,
width: "140px",
width: "160px",
},
{ key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
{
@ -166,6 +175,14 @@ export function ProjectManagementPage() {
});
const observerRef = useRef<HTMLDivElement>(null);
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) => {
// Prevent concurrent API calls
@ -197,7 +214,10 @@ export function ProjectManagementPage() {
OutputFields: outputFields,
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
Conditions: [],
Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
});
if (response.state === 0) {
@ -262,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(() => {
if (!loadingMore && hasMore && !loading) {
if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
setCurrentPage((prev) => prev + 1);
}
}, [loadingMore, hasMore, loading]);
}, [hasMore, loading, loadingMore]);
useEffect(() => {
fetchProjects(true);
fetchTotalCount();
}, [sortConfig]);
if (date.end && date.start) {
fetchProjects(true);
fetchTotalCount();
}
}, [sortConfig, date]);
useEffect(() => {
if (currentPage > 1) {
@ -279,30 +312,44 @@ export function ProjectManagementPage() {
}
}, [currentPage]);
// Infinite scroll observer
// Infinite scroll observer with debouncing
useEffect(() => {
const scrollContainer = document.querySelector(".overflow-auto");
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return;
if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Trigger load more when scrolled to 90% of the container
if (scrollPercentage >= 0.9) {
loadMore();
// Clear previous timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Debounce scroll events
scrollTimeoutRef.current = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Trigger load more when scrolled to 95% of the container
if (scrollPercentage >= 0.95) {
loadMore();
}
}, 150);
};
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
scrollContainer.addEventListener("scroll", handleScroll, {
passive: true,
});
}
return () => {
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [loadMore, hasMore, loadingMore]);
@ -323,7 +370,10 @@ export function ProjectManagementPage() {
const response = await apiService.select({
ProcessName: "project",
OutputFields: ["count(project_no)"],
Conditions: [],
Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
});
if (response.state === 0) {
@ -344,14 +394,14 @@ export function ProjectManagementPage() {
}
};
const handleRefresh = () => {
fetchingRef.current = false; // Reset fetching state on refresh
setCurrentPage(1);
setProjects([]);
setHasMore(true);
fetchProjects(true);
fetchTotalCount();
};
// const handleRefresh = () => {
// fetchingRef.current = false; // Reset fetching state on refresh
// setCurrentPage(1);
// setProjects([]);
// setHasMore(true);
// fetchProjects(true);
// fetchTotalCount();
// };
// ...existing code...
@ -495,6 +545,160 @@ export function ProjectManagementPage() {
}
};
// Categories for which we'll generate/display color legends
const categoryDefs = [
{
key: "strategic_theme",
label: "مضمون راهبردی",
palette: ["#6D53FB", "#7C3AED", "#5B21B6", "#4C1D95", "#A78BFA"],
},
{
key: "value_technology_and_innovation",
label: "ارزش فناوری و نوآوری",
palette: ["#A757FF", "#C084FC", "#8B5CF6", "#7C3AED", "#D8B4FE"],
},
{
key: "type_of_innovation",
label: "انواع نوآوری",
palette: ["#E884CE", "#FB7185", "#F472B6", "#F97316", "#FBCFE8"],
},
{
key: "innovation",
label: "میزان نوآوری",
palette: ["#C3BF8B", "#10B981", "#F59E0B", "#EF4444", "#FDE68A"],
},
{
key: "executive_phase",
label: "فاز اجرایی",
palette: ["#C3BF8B", "#10B981", "#F59E0B", "#EF4444", "#FDE68A"],
},
];
// Build a mapping of value -> color for each category based on loaded projects.
// We assign colors deterministically from the category palette in order of appearance.
const categoryColorMaps = useMemo(() => {
const maps: Record<string, Record<string, string>> = {};
categoryDefs.forEach((cat) => {
maps[cat.key] = {};
const seen = new Map<string, string>();
const values: string[] = projects
.map((p) => (p as any)[cat.key])
.filter((v) => v !== undefined && v !== null && String(v).trim() !== "")
.map((v) => String(v));
// preserve order of first appearance
values.forEach((val, idx) => {
if (!seen.has(val)) {
const color = cat.palette[seen.size % cat.palette.length];
seen.set(val, color);
}
});
seen.forEach((color, val) => {
maps[cat.key][val] = color;
});
});
return maps;
}, [projects]);
// Compute counts and totals for each category so footer segments can be proportional
const categoryStats = useMemo(() => {
const stats: Record<
string,
{ counts: Record<string, number>; total: number }
> = {};
categoryDefs.forEach((cat) => {
const counts: Record<string, number> = {};
let total = 0;
projects.forEach((p) => {
const val = String((p as any)[cat.key] ?? "").trim();
if (val !== "") {
counts[val] = (counts[val] || 0) + 1;
total += 1;
}
});
stats[cat.key] = { counts, total };
});
// also compute executive_phase counts
const execCounts: Record<string, number> = {};
let execTotal = 0;
projects.forEach((p) => {
const val = String((p as any)["executive_phase"] ?? "").trim();
if (val !== "") {
execCounts[val] = (execCounts[val] || 0) + 1;
execTotal += 1;
}
});
stats["executive_phase"] = { counts: execCounts, total: execTotal };
return stats;
}, [projects]);
// Importance counts (بالا، متوسط، پایین) for footer bar
const importanceCounts = useMemo(() => {
const counts: Record<string, number> = {};
let total = 0;
projects.forEach((p) => {
const val = String((p as any).importance_project ?? "").trim();
if (val !== "") {
counts[val] = (counts[val] || 0) + 1;
total += 1;
}
});
return { counts, total };
}, [projects]);
// Numeric averages for specified columns
const numericAverages = useMemo(() => {
const keys = [
"remaining_time",
"renewed_duration",
"deviation_from_program",
"approved_budget",
"budget_spent",
"cost_deviation",
];
const res: Record<string, number | null> = {};
// remaining_time is computed from end_date
const remainingValues: number[] = projects
.map((p) => calculateRemainingDays((p as any).end_date))
.filter((v) => v !== null) as number[];
res["remaining_time"] = remainingValues.length
? Math.round(
remainingValues.reduce((a, b) => a + b, 0) / remainingValues.length
)
: null;
// For other keys, parse numeric values
keys.forEach((k) => {
if (k === "remaining_time") return;
const vals: number[] = projects
.map((p) => {
const raw = (p as any)[k];
if (raw == null) return NaN;
const num = Number(
String(raw)
.toString()
.replace(/[^0-9.-]/g, "")
);
return Number.isFinite(num) ? num : NaN;
})
.filter((n) => !Number.isNaN(n));
res[k] = vals.length
? vals.reduce((a, b) => a + b, 0) / vals.length
: null;
});
return res;
}, [projects]);
const getCategoryColor = (categoryKey: string, value: unknown) => {
const val = value == null ? "" : String(value);
const map = categoryColorMaps[categoryKey] || {};
return map[val] ?? "#6B7280"; // fallback gray
};
const renderCellContent = (item: ProjectData, column: ColumnDef) => {
const apiField = column.apiField ?? column.key;
const value = (item as any)[apiField];
@ -509,7 +713,7 @@ export function ProjectManagementPage() {
return (
<span
dir="ltr"
className="font-medium flex justify-end gap-1 items-center"
className="flex justify-end gap-1 items-center"
style={{ color }}
>
<span>روز</span> {toPersianDigits(days)}
@ -520,55 +724,65 @@ export function ProjectManagementPage() {
case "value_technology_and_innovation":
case "type_of_innovation":
case "innovation":
case "executive_phase": {
const color = getCategoryColor(column.key, value);
return (
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
<span className="text-gray-300">{String(value) || "-"}</span>
<span className="text-gray-300">
{!!value ? String(value) : "-"}
</span>
<span
style={{
backgroundColor: `${column.key === "strategic_theme" ? "#6D53FB" : column.key === "value_technology_and_innovation" ? "#A757FF" : column.key === "type_of_innovation" ? "#E884CE" : "#C3BF8B"}`,
backgroundColor: color,
display: !value ? "none" : "block",
}}
className="inline-block w-2 h-2 rounded-full bg-emerald-400"
className="inline-block w-2 h-2 rounded-full"
/>
</span>
);
}
case "approved_budget":
case "budget_spent":
return (
<span className="font-medium text-emerald-400">
<span className=" text-emerald-400 font-normal">
{formatCurrency(String(value))}
</span>
);
case "deviation_from_program":
case "cost_deviation":
return (
<span className="text-gray-300">{formatNumber(value as any)}</span>
<span className="text-sm font-normal">
{formatNumber(value as any)}
</span>
);
case "start_date":
case "end_date":
case "done_date":
return (
<span className="text-gray-300">{formatDate(String(value))}</span>
<span className=" text-sm font-normal">
{formatDate(String(value))}
</span>
);
case "project_no":
return (
<Badge
variant="outline"
className="font-mono text-emerald-400 border-emerald-500/50"
>
<Badge variant="teal" className="border-emerald-500/50">
{String(value)}
</Badge>
);
case "title":
return <span className="font-medium text-white">{String(value)}</span>;
return (
<span className="text-sm font-normal text-white">
{String(value)}
</span>
);
case "importance_project":
return (
<Badge
variant="outline"
className="font-medium border-2"
className="border-2 text-sm rounded-lg"
style={{
color: getImportanceColor(String(value)),
borderColor: getImportanceColor(String(value)),
backgroundColor: `${getImportanceColor(String(value))}20`,
}}
>
{String(value)}
@ -576,108 +790,356 @@ export function ProjectManagementPage() {
);
default:
return (
<span className="text-gray-300">
<span className="font-light text-sm">
{(value && String(value)) || "-"}
</span>
);
}
};
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 (
<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 */}
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
{/* <div onClick={exportToExcel}>DownloadExcle</div> */}
<CardContent className="p-0">
<div className="relative">
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(100vh-200px)]">
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
<TableHead
key={column.key}
className="text-right font-persian whitespace-nowrap text-gray-200 font-medium bg-[#3F415A] sticky top-0 z-20"
style={{ width: column.width }}
>
{column.sortable ? (
<button
onClick={() => handleSort(column.key)}
className="flex items-center gap-2"
>
<span>{column.label}</span>
{sortConfig.field === column.key ? (
sortConfig.direction === "asc" ? (
<ChevronUp className="w-4 h-4" />
<div
ref={scrollContainerRef}
className="relative overflow-auto custom-scrollbar max-h-[calc(100vh-120px)]"
>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
<TableHead
key={column.key}
className={` text-right font-persian whitespace-nowrap text-white font-semibold bg-[#3F415A] sticky top-0 z-20`}
style={{ width: column.width }}
>
{column.sortable ? (
<button
onClick={() => handleSort(column.key)}
className="flex items-center gap-2"
>
<span>{column.label}</span>
{sortConfig.field === column.key ? (
sortConfig.direction === "asc" ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
) : (
column.label
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
// Skeleton loading rows (compact)
Array.from({ length: 20 }).map((_, index) => (
<TableRow
key={`skeleton-${index}`}
className="text-sm leading-tight h-8"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-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="h-2.5 bg-gray-600 rounded animate-pulse"
style={{ width: `${Math.random() * 60 + 40}%` }}
/>
</div>
</TableCell>
))}
</TableRow>
))
) : projects.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8"
>
<span className="text-gray-400 font-persian">
هیچ پروژهای یافت نشد
</span>
</TableCell>
<div className="w-4 h-4" />
)}
</button>
) : (
column.label
)}
</TableHead>
))}
</TableRow>
) : (
projects.map((project, index) => (
<TableRow
key={`${project.project_no}-${index}`}
className="text-sm leading-tight h-8"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
>
{renderCellContent(project, column)}
</TableCell>
))}
</TableHeader>
<TableBody>
{loading ? (
// Skeleton loading rows (compact)
Array.from({ length: 20 }).map((_, index) => (
<TableRow
key={`skeleton-${index}`}
className="text-sm leading-tight h-8"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right border-emerald-500/20 py-1 px-2 break-words"
>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
<div
className="h-2.5 bg-gray-600 rounded animate-pulse"
style={{
width: `${Math.random() * 60 + 40}%`,
}}
/>
</div>
</TableCell>
))}
</TableRow>
))
) : projects.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8"
>
<span className="text-gray-400 font-persian">
هیچ پروژهای یافت نشد
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
projects.map((project, index) => (
<TableRow
key={`${project.project_no}-${index}`}
className="text-sm leading-tight h-8"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right border-emerald-500/20 text-sm py-1 px-2 break-words"
>
{renderCellContent(project, column)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
<TableFooter className="sticky py-2 bottom-[-1px] bg-[#3F415A]">
<TableRow>
{columns.map((column, colIndex) => {
// First column: show total projects text similar to API count
if (colIndex === 0) {
return (
<TableCell
key={column.key}
className="p-3 text-sm text-white font-semibold font-persian"
>
کل پروژهها: {formatNumber(actualTotalCount)}
</TableCell>
);
}
// importance_project: render importance bar with specified colors
if (column.key === "importance_project") {
const imp = importanceCounts;
const order = ["بالا", "متوسط", "پایین"];
const colorFor = (k: string) => {
switch (k) {
case "بالا":
return "var(--color-pr-green)"; // green
case "متوسط":
return "#69C8EA"; // blue-ish
case "پایین":
return "#F76276"; // red
default:
return "#6B7280";
}
};
return (
<TableCell key={column.key} className="p-1">
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
{order.map((k) => {
const cnt = imp.counts[k] || 0;
const widthPercent =
imp.total > 0 ? (cnt / imp.total) * 100 : 0;
return (
<div
key={k}
title={`${k} (${cnt})`}
className="h-3 flex items-center justify-center text-xs font-medium"
style={{
width: `${widthPercent}%`,
backgroundColor: colorFor(k),
}}
></div>
);
})}
</div>
</TableCell>
);
}
// For category-like columns: strategic_theme, value_technology_and_innovation, innovation, executive_phase
const categoryLike = [
"strategic_theme",
"value_technology_and_innovation",
"innovation",
"executive_phase",
];
if (categoryLike.includes(column.key)) {
const stat = categoryStats[column.key] || {
counts: {},
total: 0,
};
const entries = Object.entries(stat.counts);
return (
<TableCell key={column.key} className="p-1">
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
{entries.length > 0 ? (
entries.map(([val, cnt]) => {
let color =
categoryColorMaps[column.key]?.[val] ||
"#6B7280";
if (column.key === "executive_phase") {
color =
(phaseColors as any)[val] || color;
}
const widthPercent =
stat.total > 0
? (cnt / stat.total) * 100
: 0;
return (
<div
key={val}
title={`${val} (${cnt})`}
className="h-3 flex items-center justify-center text-xs font-medium"
style={{
width: `${widthPercent}%`,
backgroundColor: color,
}}
></div>
);
})
) : (
<div className="h-3 w-full bg-gray-700" />
)}
</div>
</TableCell>
);
}
// remove bar for type_of_innovation (show empty cell)
if (column.key === "type_of_innovation") {
return <TableCell key={column.key} className="p-1" />;
}
// remaining_time: show average days with color (green/red/white)
if (column.key === "remaining_time") {
const avg = numericAverages["remaining_time"] as
| number
| null;
const color =
avg == null
? "#9CA3AF"
: avg > 0
? "#3AEA83"
: avg < 0
? "#F76276"
: "#FFFFFF";
return (
<TableCell
key={column.key}
className="p-2 text-right font-medium"
style={{ color }}
>
{avg == null ? "-" : `${formatNumber(avg)} روز`}
</TableCell>
);
}
// For numeric columns: show average rounded
const numericKeyMap: Record<string, string> = {
renewed_duration: "renewed_duration",
deviation_from_program: "deviation_from_program",
approved_budget: "approved_budget",
budget_spent: "budget_spent",
cost_deviation: "cost_deviation",
};
const mapped = (numericKeyMap as any)[column.key];
if (mapped) {
const avg = numericAverages[mapped] as number | null;
let display = "-";
if (avg != null) {
display = mapped.includes("budget")
? formatCurrency(String(Math.round(avg)))
: formatNumber(Math.round(avg));
}
return (
<TableCell
key={column.key}
className="p-2 text-right font-medium text-gray-200"
>
{display}
</TableCell>
);
}
// Default: empty cell to keep alignment
return <TableCell key={column.key} className="p-1" />;
})}
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* Infinite scroll trigger */}
@ -685,20 +1147,13 @@ export function ProjectManagementPage() {
{loadingMore && (
<div className="flex items-center justify-center py-1">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
<RefreshCw className="w-4 h-3 animate-spin text-emerald-400" />
<span className="font-persian text-gray-300 text-xs"></span>
</div>
</div>
)}
</div>
</CardContent>
{/* Footer */}
<div className="p-4 bg-gray-700/50">
<div className="flex items-center justify-between text-sm text-gray-300 font-persian">
<span>کل پروژهها: {formatNumber(actualTotalCount)}</span>
</div>
</div>
</Card>
</div>
</DashboardLayout>

View File

@ -1,32 +1,33 @@
import {
Box,
Building2,
ChevronDown,
ChevronRight,
FolderKanban,
GalleryVerticalEnd,
Globe,
LayoutDashboard,
Leaf,
Lightbulb,
House,
LightbulbIcon,
ListTodo,
LogOut,
MonitorSmartphone,
Package,
Radar,
Settings,
Star,
Workflow,
DiscAlbum
DiscAlbum,
LucideLightbulb
} from "lucide-react";
import React, { useState } from "react";
import { Link, useLocation } from "react-router";
import { useAuth } from "~/contexts/auth-context";
import { cn } from "~/lib/utils";
interface TitleInfo {
title: string;
icon?: React.ComponentType<{ className?: string }> | null;
}
interface SidebarProps {
isCollapsed?: boolean;
onToggleCollapse?: () => void;
className?: string;
onStrategicAlignmentClick?: () => void;
onTitleChange?: (info: TitleInfo) => void;
}
interface MenuItem {
@ -39,52 +40,51 @@ interface MenuItem {
}
const menuItems: MenuItem[] = [
{
id: "dashboard",
label: "صفحه اصلی",
icon: LayoutDashboard,
icon: House,
href: "/dashboard",
},
{
id: "project-management",
label: "مدیریت اجرای پروژه‌ها",
icon: FolderKanban,
icon: ListTodo,
href: "/dashboard/project-management",
},
{
id: "innovation-basket",
label: "سبد فناوری و نوآوری",
icon: Box,
icon: LightbulbIcon,
children: [
{
id: "product-innovation",
label: "نوآوری در محصول",
icon: Package,
icon: null,
href: "/dashboard/innovation-basket/product-innovation",
},
{
id: "process-innovation",
label: "نوآوری در فرآیند",
icon: Workflow,
icon: null,
href: "/dashboard/innovation-basket/process-innovation",
},
{
id: "digital-innovation",
label: "نوآوری دیجیتال",
icon: MonitorSmartphone,
icon: null,
href: "/dashboard/innovation-basket/digital-innovation",
},
{
id: "green-innovation",
label: "نوآوری سبز",
icon: Leaf,
icon: null,
href: "/dashboard/innovation-basket/green-innovation",
},
{
id: "internal-innovation",
label: "نوآوری ساخت داخل",
icon: Building2,
icon: null,
href: "/dashboard/innovation-basket/internal-innovation",
},
],
@ -92,37 +92,30 @@ const menuItems: MenuItem[] = [
{
id: "ecosystem",
label: "زیست بوم فناوری و نوآوری",
icon: Globe,
icon: Radar,
href: "/dashboard/ecosystem",
},
{
id: "ideas",
label: "ایده‌های فناوری و نوآوری",
icon: Lightbulb,
icon: LucideLightbulb,
href: "/dashboard/manage-ideas-tech",
},
{
id: "top-innovations",
label: "نوآور برتر",
icon: Star,
href: "/dashboard/top-innovations",
},
{
{
id: "strategic-alignment",
label: "میزان انطباق راهبردی",
icon: null,
href: "#", // This is not a route, it opens a popup
},
];
const bottomMenuItems: MenuItem[] = [
{
id: "settings",
label: "تنظیمات",
icon: Settings,
href: "/dashboard/settings",
},
// {
// id: "settings",
// label: "تنظیمات",
// icon: Settings,
// href: "/dashboard/settings",
// },
{
id: "logout",
label: "خروج",
@ -136,6 +129,7 @@ export function Sidebar({
onToggleCollapse,
className,
onStrategicAlignmentClick,
onTitleChange,
}: SidebarProps) {
const location = useLocation();
const [expandedItems, setExpandedItems] = useState<string[]>([]);
@ -158,6 +152,35 @@ export function Sidebar({
});
setExpandedItems(newExpandedItems);
// Update header title based on current route
// If a child route is active, use that child's label prefixed by parent label
let activeTitle: string | undefined = undefined;
let activeIcon:
| React.ComponentType<{ className?: string }>
| null
| undefined = undefined;
menuItems.forEach((item) => {
if (item.children) {
const activeChild = item.children.find(
(child) => child.href && location.pathname === child.href
);
if (activeChild) {
activeTitle = `${item.label}-${activeChild.label}`;
// prefer child icon for the page; fallback to parent
activeIcon = activeChild.icon ?? item.icon ?? null;
}
}
if (!activeTitle && item.href && location.pathname === item.href) {
activeTitle = item.label;
activeIcon = item.icon ?? null;
}
});
if (onTitleChange) {
onTitleChange({
title: activeTitle ?? "صفحه اول",
icon: activeIcon ?? null,
});
}
};
autoExpandParents();
@ -207,8 +230,13 @@ export function Sidebar({
const ItemIcon = item.icon;
const handleClick = () => {
// Only update header title for navigable items (those with href)
if (item.href && item.href !== "#") {
const icon = item.icon ?? null;
onTitleChange?.({ title: item.label, icon });
}
if (item.id === "strategic-alignment") {
console.log("test")
onStrategicAlignmentClick?.();
} else if (item.id === "logout") {
logout();
@ -222,7 +250,7 @@ export function Sidebar({
<button
key={item.id}
className={cn(
"flex items-center justify-center w-full px-2 rounded-lg 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}
>
@ -232,8 +260,7 @@ export function Sidebar({
</span>
</div>
</button>
)
);
}
return (
@ -242,22 +269,24 @@ export function Sidebar({
<Link to={item.href} className="block">
<div
className={cn(
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
"flex items-center justify-between rounded-none w-full py-2 px-3 transition-all duration-200 group",
level === 0 ? "mb-1" : "mb-0.5 mr-4",
isActive
? " text-emerald-400 border-r-2 border-emerald-400"
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
? " text-pr-green border-r-2 border-pr-green"
: "text-gray-300 hover:text-pr-green",
isCollapsed && level === 0 && "justify-center px-2",
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400"
item.id === "logout" && "hover:text-pr-red"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-emerald-400" : "text-current"
)}
/>
{ItemIcon && (
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-pr-green" : "text-current"
)}
/>
)}
{!isCollapsed && (
<span className="font-persian text-sm font-medium truncate">
{item.label}
@ -268,7 +297,7 @@ export function Sidebar({
{!isCollapsed && (
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge && (
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-pr-green text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
{item.badge}
</span>
)}
@ -290,31 +319,33 @@ export function Sidebar({
"w-full text-right",
// Disable pointer cursor when child is active (cannot collapse)
item.children &&
item.children.some(
(child) => child.href && location.pathname === child.href
) &&
"cursor-not-allowed"
item.children.some(
(child) => child.href && location.pathname === child.href
) &&
"cursor-not-allowed"
)}
onClick={handleClick}
>
<div
className={cn(
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
"flex items-center justify-between w-full py-2 px-3 rounded-none transition-all duration-200 group",
level === 0 ? "mb-1" : "mb-0.5 mr-4",
isActive
? " text-emerald-400 border-r-2 border-emerald-400"
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
? " text-pr-green border-r-2 border-pr-green"
: "text-gray-300 cursor-pointer hover:text-pr-green",
isCollapsed && level === 0 && "justify-center px-2",
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400"
item.id === "logout" && "hover:text-pr-red"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-emerald-400" : "text-current"
)}
/>
{ItemIcon && (
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-pr-green" : "text-current"
)}
/>
)}
{!isCollapsed && (
<span className="font-persian text-sm font-medium truncate">
{item.label}
@ -325,7 +356,7 @@ export function Sidebar({
{!isCollapsed && (
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge && (
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/10 text-emerald-400 text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/10 text-pr-green text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
{item.badge}
</span>
)}
@ -340,7 +371,7 @@ export function Sidebar({
(child) =>
child.href && location.pathname === child.href
)
? "text-emerald-400"
? "text-pr-green"
: "text-current"
)}
/>
@ -392,9 +423,9 @@ export function Sidebar({
/>
<div className="font-persian">
<div className="text-sm font-semibold text-white">
داشبورد اینوژن
داشبورد مدیریت فناوری و نوآوری
</div>
<div className="text-xs text-gray-400">نسخه ۰.۱</div>
{/* <div className="text-xs text-gray-400">نسخه ۰.۱</div> */}
</div>
</div>
) : (
@ -431,7 +462,7 @@ export function Sidebar({
</div>
{/* Collapse Toggle */}
{onToggleCollapse && (
{/* {onToggleCollapse && (
<div className="p-3 border-t border-gray-500/30">
<button
onClick={onToggleCollapse}
@ -448,7 +479,7 @@ export function Sidebar({
)}
</button>
</div>
)}
)} */}
</div>
);
}

View File

@ -1,33 +1,80 @@
import React, { useEffect, useState } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
BarChart,
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ResponsiveContainer,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LabelList,
Cell,
} from "recharts";
import apiService from "~/lib/api";
import { Dialog, DialogContent, DialogHeader } from "~/components/ui/dialog";
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 {
DropdownMenu,
DropdownMenuButton,
DropdownMenuContent,
DropdownMenuItem,
} from "../ui/dropdown-menu";
import { TruncatedText } from "../ui/truncatedText";
interface StrategicAlignmentData {
strategic_theme: string;
operational_fee_sum: number;
operational_fee_count: 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 {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -41,11 +88,10 @@ const chartConfig = {
},
};
const maxHeight = 150;
const barHeights = () => Math.floor(Math.random() * maxHeight);
const maxHeight = 150;
const barHeights = () => Math.floor(Math.random() * maxHeight);
const ChartSkeleton = () => (
<div className="flex justify-center h-96 w-full p-4">
{/* Chart bars */}
<div className=" w-full flex items-end gap-10">
@ -58,7 +104,7 @@ const ChartSkeleton = () => (
</div>
))}
</div>
{/* Left space for Y-axis label */}
{/* Left space for Y-axis label */}
<div className="flex flex-col justify-between mr-2">
<Skeleton className="h-6 w-15 bg-gray-700 rounded" />
<Skeleton className="h-6 w-15 bg-gray-700 rounded" />
@ -74,6 +120,14 @@ export function StrategicAlignmentPopup({
}: StrategicAlignmentPopupProps) {
const [data, setData] = useState<StrategicAlignmentData[]>([]);
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(() => {
if (open) {
@ -81,16 +135,29 @@ export function StrategicAlignmentPopup({
}
}, [open]);
useEffect(() => {
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
}, []);
const fetchData = async () => {
setLoading(true);
try {
const response = await apiService.select({
ProcessName: "project",
OutputFields: [
"strategic_theme",
"sum(operational_fee) as operational_fee_sum",
],
OutputFields: ["strategic_theme", "count(operational_fee)"],
GroupBy: ["strategic_theme"],
Conditions: [
["start_date", ">=", date?.start || null, "and"],
["start_date", "<=", date?.end || null],
],
});
const responseData =
@ -98,29 +165,12 @@ export function StrategicAlignmentPopup({
? JSON.parse(response.data)
: response.data;
const processedData = responseData
.map((item: any) => ({
strategic_theme: item.strategic_theme || "N/A",
operational_fee_sum: Math.max(0, Number(item.operational_fee_sum)),
}))
.filter((item: StrategicAlignmentData) => item.strategic_theme !== "");
const total = processedData.reduce(
(acc: number, item: StrategicAlignmentData) =>
acc + item.operational_fee_sum,
0
setBarItems(responseData);
const dropDownItems = responseData.map(
(item: any) => item.strategic_theme
);
const dataWithPercentage = processedData.map(
(item: StrategicAlignmentData) => ({
...item,
percentage:
total > 0
? Math.round((item.operational_fee_sum / total) * 100)
: 0,
})
);
setData(dataWithPercentage || []);
setDropDownValues(["همه مضامین", ...dropDownItems]);
} catch (error) {
console.error("Error fetching strategic alignment data:", error);
} finally {
@ -128,19 +178,174 @@ export function StrategicAlignmentPopup({
}
};
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
.map((item: any) => ({
strategic_theme:
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 !== "");
const total = processedData.reduce(
(acc: number, item: StrategicAlignmentData) =>
acc + item.operational_fee_count,
0
);
const dataWithPercentage = processedData.map(
(item: StrategicAlignmentData) => ({
...item,
percentage:
total > 0
? Math.round((item.operational_fee_count / total) * 100)
: 0,
})
);
setData(dataWithPercentage || []);
};
const dialogHandler = (status: boolean) => {
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 (
<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">
<DialogHeader className="border-b-3 mb-10 py-2 w-full pb-4 border-b-2 border-gray-500/20">
<DialogTitle className="ml-auto text-sm text-white ">میزان انطباق راهبردی</DialogTitle>
<DialogHeader className="mb-10 w-full border-b-2 border-gray-500/20">
<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>
{loading ? (
<ChartSkeleton />
) : (
<>
<ResponsiveContainer width="100%" height={400}>
<ChartContainer config={chartConfig} className="aspect-auto h-96 w-full">
<ResponsiveContainer width="100%" height={400}>
<ChartContainer
config={chartConfig}
className="aspect-auto h-96 w-full"
>
<BarChart
data={data}
margin={{ left: 12, right: 12 }}
@ -149,7 +354,7 @@ export function StrategicAlignmentPopup({
accessibilityLayer
>
<CartesianGrid vertical={false} stroke="#475569" />
<XAxis
<XAxis
dataKey="strategic_theme"
tickLine={false}
axisLine={false}
@ -161,11 +366,8 @@ export function StrategicAlignmentPopup({
return (
<g transform={`translate(${x},${y})`}>
<foreignObject width={80} height={20} x={-45} y={0}>
<TruncatedText
maxWords={2}
text={payload.value}
/>
</foreignObject>
<TruncatedText maxWords={2} text={payload.value} />
</foreignObject>
</g>
);
}}
@ -179,37 +381,38 @@ export function StrategicAlignmentPopup({
tickFormatter={(value) =>
`${formatNumber(Math.round(value))}`
}
label={{
value: "تعداد برنامه ها" ,
angle: -90,
position: "insideLeft",
fill: "#94a3b8",
fontSize: 11,
offset: 0,
dy: 0,
style: { textAnchor: "middle" },
}}
label={{
value: "تعداد برنامه ها",
angle: -90,
position: "insideLeft",
fill: "#94a3b8",
fontSize: 11,
offset: 0,
dy: 0,
style: { textAnchor: "middle" },
}}
/>
<Bar dataKey="percentage" radius={[8, 8, 0, 0]}>
<Bar dataKey="percentage" radius={[8, 8, 0, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={chartConfig.percentage.color} />
<Cell
key={`cell-${index}`}
fill={chartConfig.percentage.color}
/>
))}
<LabelList
dataKey="percentage"
position="top"
offset={15}
offset={15}
style={{
fill: "#ffffff",
fontSize: "16px",
fontWeight: "bold",
}}
formatter={(v: number) => `${formatNumber(Math.round(v))}`}
formatter={(v: number) =>
`${formatNumber(Math.round(v))}`
}
/>
</Bar>
</BarChart>
</ChartContainer>

View File

@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { useEffect, useState } from "react";
import {
Area,
AreaChart,
@ -11,9 +10,12 @@ import {
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
import { useStoredDate } from "~/hooks/useStoredDate";
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 {
id: string;
@ -62,38 +64,59 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
const [processData, setProcessData] = useState<ProcessActorsData[]>([]);
const [isLoading, setIsLoading] = useState(true);
// const [date, setDate] = useState<CalendarDate>();
const [date, setDate] = useStoredDate();
useEffect(() => {
const fetchCounts = async () => {
setIsLoading(true);
try {
const [countsRes, processRes] = await Promise.all([
apiService.call<EcosystemCounts>({
ecosystem_count_function: {},
}),
apiService.call<ProcessActorsResponse[]>({
process_creating_actors_function: {},
}),
]);
setCounts(
JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0],
);
// Process the years data and fill missing years
const processedData = processYearsData(
JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors),
);
setProcessData(processedData);
} catch (err) {
console.error("Failed to fetch data:", err);
} finally {
setIsLoading(false);
}
const handler = (date: CalendarDate) => {
if (date) setDate(date);
};
EventBus.on("dateSelected", handler);
return () => {
EventBus.off("dateSelected", handler);
};
fetchCounts();
}, []);
useEffect(() => {
if (date.end && date.start) fetchCounts();
}, [date]);
const fetchCounts = async () => {
setIsLoading(true);
try {
const [countsRes, processRes] = await Promise.all([
apiService.call<EcosystemCounts>({
ecosystem_count_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}),
apiService.call<ProcessActorsResponse[]>({
process_creating_actors_function: {
start_date: date?.start || null,
end_date: date?.end || null,
},
}),
]);
setCounts(
JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0]
);
// Process the years data and fill missing years
const processedData = processYearsData(
JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors)
);
setProcessData(processedData);
} catch (err) {
console.error("Failed to fetch data:", err);
} finally {
setIsLoading(false);
}
};
// Helper function to safely parse numbers
const parseNumber = (value: string | undefined): number => {
if (!value || value === "") return 0;
@ -103,7 +126,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
// Helper function to process years data and fill missing years
const processYearsData = (
data: ProcessActorsResponse[],
data: ProcessActorsResponse[]
): ProcessActorsData[] => {
if (!data || data.length === 0) return [];
@ -121,7 +144,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
acc[item.start_year] = item.total_count;
return acc;
},
{} as Record<string, number>,
{} as Record<string, number>
);
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.university_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) => (
<div
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={{
left: `${20 + i * 25}%`,
top: `${30 + Math.random() * 40}%`,
@ -287,7 +310,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{/* Actor Count Skeleton */}
<CardHeader className="text-center pt-0 pb-4">
<div className="w-36 h-5 rounded animate-pulse mx-auto mb-2"></div>
<div className="w-16 h-8 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
<div className="w-16 h-8 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
</CardHeader>
{/* Bar Chart Skeleton */}
@ -362,7 +385,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
{Array.from({ length: 4 }).map((_, i) => (
<div
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={{
left: `${20 + i * 25}%`,
top: `${30 + Math.random() * 40}%`,
@ -378,7 +401,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
<CardContent className="pt-0 pb-6">
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center">
<div className="w-28 h-4 bg-gray-600 rounded animate-pulse mx-auto mb-1"></div>
<div className="w-12 h-6 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
<div className="w-12 h-6 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
</div>
</CardContent>
</Card>
@ -401,30 +424,22 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
<div className="space-y-4">
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
<CardHeader className="text-center pt-4 pb-3 border-b-2 border-[#3F415A]">
<CardTitle className="font-persian text-xl text-white">
<CardTitle className="font-persian text-base font-semibold text-white">
وضعیت زیستبوم فناوری و نوآوری
</CardTitle>
</CardHeader>
{/* Footer - MOU Count */}
{/* <CardContent className="py-3">
<div className="flex font-bold text-xl px-6 justify-between text-gray-300 font-persian mb-1">
تعداد تفاهم نامه ها
<span className="text-2xl">{formatNumber(counts.mou_count)}</span>
</div>
</CardContent> */}
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
<CardTitle className="font-persian text-xl text-white flex justify-between px-4">
تعداد تفاهم نامه ها
<span className="font-bold text-3xl">
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
تعداد تفاهم نامه ها
<span className="font-bold text-3xl">
{formatNumber(counts.mou_count)}
</span>
</CardTitle>
</CardHeader>
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
<CardTitle className="font-persian text-xl text-white flex justify-between px-4">
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
تعداد بازیگران
<span className="font-bold text-3xl">
{formatNumber(counts.actor_count)}
@ -433,13 +448,14 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
</CardHeader>
{/* Actor Count Display */}
<CardHeader className="text-right text-xl py-2 pb-4 font-bold w-full">
<CardHeader className="text-right pt-4 mt-2 pb-2 text-sm font-semibold w-full">
تنوع بازیگران
</CardHeader>
{/* Middle - Bar Chart */}
<CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]">
<div className="w-full">
<CustomBarChart
hasPercent={false}
data={barData.map((item) => ({
label: item.label,
value: item.value,
@ -454,55 +470,87 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
</CardContent>
{/* Area Chart Section */}
<CardContent className="px-2 pb-4 border-b-2 border-[#3F415A] py-4">
<div className="mb-4">
<CardTitle className="font-persian text-lg text-white mb-2">
<CardContent className="p-2">
<div className="px-4">
<CardTitle className="font-persian text-sm font-semibold text-white mb-2">
روند ایجاد بازیگران در طول سالها
</CardTitle>
</div>
<div className="h-48">
<div className="h-42">
{processData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
accessibilityLayer
data={processData}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
margin={{ top: 25, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient
id="fillDesktop"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="#3AEA83" stopOpacity={1} />
<stop offset="100%" stopColor="#3AEA83" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="rgba(255,255,255,0.1)"
/>
<XAxis
dataKey="year"
stroke="#9ca3af"
fontSize={12}
tickLine={false}
tickMargin={8}
axisLine={false}
tickFormatter={formatPersianYear}
/>
<YAxis
stroke="#9ca3af"
fontSize={12}
tickMargin={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatNumber(value)}
/>
<Tooltip
contentStyle={{
backgroundColor: "#374151",
border: "1px solid #6b7280",
borderRadius: "6px",
color: "#f3f4f6",
}}
labelFormatter={(value) =>
`سال ${formatPersianYear(value.toString())}`
}
formatter={(value) => [
formatNumber(value),
"تعداد بازیگران",
]}
/>
<Tooltip cursor={false} content={<></>} />
{/* ✅ Use gradient for fill */}
<Area
type="monotone"
dataKey="value"
stroke="#34d399"
fill="rgba(52, 211, 153, 0.25)"
stroke="#3AEA83"
fill="url(#fillDesktop)"
strokeWidth={2}
activeDot={({ cx, cy, payload }) => (
<g>
{/* Small circle */}
<circle
cx={cx}
cy={cy}
r={5}
fill="#3AEA83"
stroke="#fff"
strokeWidth={2}
/>
{/* Year label above point */}
<text
x={cx}
y={cy - 10}
textAnchor="middle"
fontSize={12}
fontWeight="bold"
fill="#3AEA83"
>
{formatPersianYear(payload.year)}
</text>
</g>
)}
/>
</AreaChart>
</ResponsiveContainer>
@ -513,7 +561,6 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
)}
</div>
</CardContent>
</Card>
</div>
);

View File

@ -1,11 +1,19 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
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 apiService from "../../lib/api";
// Get API base URL at module level to avoid process.env access in browser
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 {
id: string;
@ -44,9 +52,9 @@ export interface CompanyDetails {
export interface NetworkGraphProps {
onNodeClick?: (node: CompanyDetails) => void;
onLoadingChange?: (loading: boolean) => void;
}
// Helper to robustly parse backend response
function parseApiResponse(raw: any): any[] {
let data = raw;
try {
@ -56,12 +64,14 @@ function parseApiResponse(raw: any): any[] {
return Array.isArray(data) ? data : [];
}
// Check if we're in browser environment
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
export function NetworkGraph({
onNodeClick,
onLoadingChange,
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement | null>(null);
const [nodes, setNodes] = useState<Node[]>([]);
const [links, setLinks] = useState<Link[]>([]);
@ -70,7 +80,21 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
const [error, setError] = useState<string | null>(null);
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(() => {
if (isBrowser()) {
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(() => {
if (!isMounted) return;
@ -89,28 +133,45 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
setIsLoading(true);
try {
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;
const data = parseApiResponse(JSON.parse(res.data)?.graph_production);
console.log(
"All available fields in first item:",
Object.keys(data[0] || {}),
Object.keys(data[0] || {})
);
// Create center node
// نود مرکزی
const centerNode: Node = {
id: "center",
label: "پتروشیمی بندر امام", //مرکز زیست بوم
// label: "پتروشیمی بندر امام",
// label: "پتروشیمی نوری",
label: "پتروشیمی آپادانا",
category: "center",
stageid: 0,
isCenter: true,
};
// Create ecosystem nodes
const ecosystemNodes: Node[] = data.map((item: any) => ({
id: String(item.stageid),
// دسته‌بندی‌ها
const categories = Array.from(
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,
category: item.category,
stageid: item.stageid,
@ -118,13 +179,16 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
rawData: item,
}));
// Create links (all nodes connected to center)
const graphLinks: Link[] = ecosystemNodes.map((node) => ({
source: "center",
target: node.id,
}));
// لینک‌ها: مرکز → دسته‌بندی‌ها → نودهای نهایی
const graphLinks: Link[] = [
...categoryNodes.map((cat) => ({ source: "center", target: cat.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);
} catch (err: any) {
if (err.name !== "AbortError") {
@ -142,43 +206,19 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
aborted = true;
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(() => {
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0) {
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0)
return;
}
const svg = d3.select(svgRef.current);
const width = svgRef.current.clientWidth;
const height = svgRef.current.clientHeight;
// Clear previous content
svg.selectAll("*").remove();
// Create defs for patterns and filters
const defs = svg.append("defs");
// Add glow filter for hover effect
const filter = defs
.append("filter")
.attr("id", "glow")
@ -196,33 +236,27 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Create zoom behavior
const container = svg.append("g");
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.8, 2.5]) // Limit zoom out to 1x, zoom in to 2.5x
.on("zoom", (event) => {
container.attr("transform", event.transform);
});
.scaleExtent([0.3, 2.5])
.on("zoom", (event) => container.attr("transform", event.transform));
svg.call(zoom);
// Create container group
const container = svg.append("g");
// Category colors
const categoryToColor: Record<string, string> = {
دانشگاه: "#3B82F6",
مشاور: "#10B981",
"دانش بنیان": "#F59E0B",
استارتاپ: "#EF4444",
شرکت: "#8B5CF6",
"تامین کننده": "#8B5CF6",
صندوق: "#06B6D4",
شتابدهنده: "#9333EA",
"مرکز نوآوری": "#F472B6",
center: "#34D399",
};
// Create force simulation
const simulation = d3
.forceSimulation<Node>(nodes)
.force(
@ -231,16 +265,21 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.forceLink<Node, Link>(links)
.id((d) => d.id)
.distance(150)
.strength(0.1),
.strength(0.2)
)
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2))
.force(
"radial",
d3.forceRadial((d) => (d.isCenter ? 0 : 300), width / 2, height / 2)
)
.force(
"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 = [
width / 2 - (width / 2) * initialScale,
height / 2 - (height / 2) * initialScale,
@ -249,37 +288,69 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
zoom.transform,
d3.zoomIdentity
.translate(initialTranslate[0], initialTranslate[1])
.scale(initialScale),
.scale(initialScale)
);
// Fix center node position
// Fix center node
const centerNode = nodes.find((n) => n.isCenter);
const categoryNodes = nodes.filter((n) => !n.isCenter && n.stageid === -1);
if (centerNode) {
centerNode.fx = width / 2;
centerNode.fy = height / 2;
const centerX = width / 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
.selectAll(".link")
.data(links)
.enter()
.append("line")
.append("path")
.attr("class", "link")
.attr("stroke", "#E2E8F0")
.attr("stroke-width", 2)
.attr("stroke-opacity", 0.6);
.attr("stroke-opacity", 0.6)
.attr("fill", "none");
// Create node groups
const nodeGroup = container
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", "node")
.style("cursor", "pointer");
.style("cursor", (d) => (d.stageid === -1 ? "default" : "pointer"));
// Add drag behavior
const drag = d3
.drag<SVGGElement, Node>()
.on("start", (event, d) => {
@ -301,56 +372,100 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
nodeGroup.call(drag);
// Add node circles/rectangles
nodeGroup.each(function (d) {
const group = d3.select(this);
if (d.isCenter) {
// Center node as rectangle
const rect = group
.append("rect")
.attr("width", 150)
.attr("height", 60)
.attr("x", -75)
.attr("y", -30)
.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.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");
// Add center image if available
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);
// 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", 150)
.attr("height", 60)
.attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
.attr("preserveAspectRatio", "xMidYMid slice");
// 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})`);
}
} else {
// Regular nodes as circles
// rect.attr("fill", `url(#image-${d.id})`);
// }
// }
// راه حل ساده‌تر - ابعاد ثابت با حفظ نسبت
if (d.isCenter) {
//آپادانا
const fixedWidth = 198;
const fixedHeight = 200; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
//بندر امام
// const fixedWidth = 100;
// const fixedHeight = 80; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
//نوری
// const fixedWidth = 100;
// const fixedHeight = 80; // یا می‌توانید براساس نسبت تصویر محاسبه کنید
const rect = group
.append("rect")
.attr("width", fixedWidth)
.attr("height", fixedHeight)
.attr("x", -fixedWidth / 2)
.attr("y", -fixedHeight / 2)
.attr("rx", 8)
.attr("ry", 8)
.attr("fill", categoryToColor[d.category] || "#94A3B8")
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3)
.style("pointer-events", "none");
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", fixedWidth)
.attr("height", fixedHeight)
.attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
.attr("preserveAspectRatio", "xMidYMid meet"); // حفظ نسبت تصویر
rect.attr("fill", `url(#image-${d.id})`);
}
else {
const circle = group
.append("circle")
.attr("r", 25)
.attr("fill", categoryToColor[d.category] || "8#fff")
.attr("fill", categoryToColor[d.category] || "#fff")
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3);
// Add node image if available
if (d.imageUrl) {
const pattern = defs
.append("pattern")
@ -367,10 +482,8 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("width", 50)
.attr("height", 50)
.attr("href", d.imageUrl)
.attr("backgroundColor", "#fff")
.attr("preserveAspectRatio", "xMidYMid slice");
// Create circular clip path
defs
.append("clipPath")
.attr("id", `clip-${d.id}`)
@ -384,20 +497,33 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
}
});
// Add labels below nodes
const labels = nodeGroup
.append("text")
.text((d) => d.label)
.attr("text-anchor", "middle")
.attr("dy", (d) => (d.isCenter ? 50 : 45))
.attr("font-size", (d) => (d.isCenter ? "14px" : "12px"))
.attr("font-weight", "bold")
.attr("fill", "#F9FAFB")
.attr("stroke", "rgba(17, 24, 39, 0.95)")
.attr("stroke-width", 4)
.attr("paint-order", "stroke");
.append("text")
.text((d) => d.label)
.attr("text-anchor", "middle")
.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-weight", "bold")
.attr("fill", "#F9FAFB")
.attr("stroke", "rgba(17, 24, 39, 0.95)")
.attr("stroke-width", 4)
.attr("paint-order", "stroke");
// Add hover effects
nodeGroup
.on("mouseenter", function (event, d) {
if (d.isCenter) return;
@ -419,79 +545,88 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
.attr("stroke-width", 3);
});
// Add click handlers
nodeGroup.on("click", async function (event, d) {
event.stopPropagation();
// Don't handle center node clicks
if (d.isCenter) return;
// جلوگیری از کلیک روی مرکز و دسته‌بندی‌ها
if (d.isCenter || d.stageid === -1) return;
if (onNodeClick && d.stageid) {
// Open dialog immediately with basic info
const basicDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: [],
};
onNodeClick(basicDetails);
// Start loading
onLoadingChange?.(true);
try {
// Fetch detailed company data
const res = await callAPI(d.stageid);
if (date.start && date.end) {
const res = await callAPI(d.stageid);
const responseData = JSON.parse(res.data);
const fieldValues =
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
const responseData = JSON.parse(res.data);
const fieldValues =
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
// Filter out image fields and find description
const filteredFields = fieldValues.filter(
(field: any) =>
!["image", "img", "full_name", "about_collaboration"].includes(
field.F.toLowerCase(),
),
);
const filteredFields = fieldValues.filter(
(field: any) =>
!["image", "img", "full_name", "about_collaboration"].includes(
field.F.toLowerCase()
)
);
const descriptionField = fieldValues.find(
(field: any) =>
field.F.toLowerCase().includes("description") ||
field.F.toLowerCase().includes("about_collaboration") ||
field.F.toLowerCase().includes("about"),
);
const descriptionField = fieldValues.find(
(field: any) =>
field.F.toLowerCase().includes("description") ||
field.F.toLowerCase().includes("about_collaboration") ||
field.F.toLowerCase().includes("about")
);
const companyDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: filteredFields,
description: descriptionField?.V || undefined,
};
const companyDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: filteredFields,
description: descriptionField?.V || undefined,
};
onNodeClick(companyDetails);
onNodeClick(companyDetails);
}
} catch (error) {
console.error("Failed to fetch company details:", error);
// Fallback to basic info
const basicDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: [],
};
onNodeClick(basicDetails);
// Keep the basic details already shown
} finally {
// Stop loading
onLoadingChange?.(false);
}
}
});
// Update positions on simulation tick
simulation.on("tick", () => {
link
.attr("x1", (d) => (d.source as Node).x!)
.attr("y1", (d) => (d.source as Node).y!)
.attr("x2", (d) => (d.target as Node).x!)
.attr("y2", (d) => (d.target as Node).y!);
link.attr("d", (d: any) => {
const sx = (d.source as Node).x!;
const sy = (d.source as Node).y!;
const tx = (d.target as Node).x!;
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})`);
});
// Cleanup function
return () => {
simulation.stop();
};
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]);
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI, date]);
// Show error message
if (error) {
return (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
@ -505,10 +640,9 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
);
}
// Don't render on server side
if (!isMounted) {
return (
<div className="w-full h-full flex items-center justify-center bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
<div className="w-full h-full flex items-center justify-center bg-transparent">
<div className="text-white font-persian text-sm">
در حال بارگذاری...
</div>
@ -518,15 +652,12 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
if (isLoading) {
return (
<div className="w-full h-full relative bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
{/* Skeleton Graph Container */}
<div className="w-full h-full relative bg-transparent">
<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="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
</div>
{/* Outer Ring Nodes Skeleton */}
{Array.from({ length: 8 }).map((_, i) => {
const angle = (i * 2 * Math.PI) / 8;
const radius = 120;
@ -547,40 +678,25 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
<div
className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse"
style={{
left: "50%",
top: "40px",
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`,
transform: `rotate(${(i * 360) / 8}deg) translateX(32px)`,
transformOrigin: "left center",
}}
></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>
);
}
return (
<div className="w-full h-full relative bg-[linear-gradient(to_bottom_left,#464861,10%,#111628)] overflow-hidden">
<svg ref={svgRef} className="w-full h-full" style={{ minHeight: 500 }} />
<div className="w-full h-full">
<svg
ref={svgRef}
className="w-full h-full bg-transparent"
style={{ cursor: "grab" }}
/>
</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

@ -7,6 +7,7 @@ interface BaseCardProps {
headerClassName?: string;
contentClassName?: string;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
withHeader?: boolean;
}
@ -17,26 +18,44 @@ export function BaseCard({
contentClassName,
children,
withHeader = false,
icon: Icon,
}: BaseCardProps) {
return (
<Card
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
)}
>
{withHeader && title ? (
<CardHeader 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>
{Icon && title ? (
<CardHeader
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>
) : withHeader && title ? (
<CardHeader
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>
</CardHeader>
) : title ? (
<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>
) : null}
<CardContent className={cn("py-2 px-4", contentClassName)}>
<CardContent className={cn("py-2 px-4 ", contentClassName)}>
{children}
</CardContent>
</Card>
);
}
}

View File

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm ",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}

View File

@ -1,4 +1,4 @@
import { formatNumber } from "~/lib/utils";
import { calculateNiceRange, formatNumber } from "~/lib/utils";
export interface BarChartData {
label: string;
@ -18,6 +18,7 @@ interface CustomBarChartProps {
showAxisLabels?: boolean;
className?: string;
loading?: boolean;
hasPercent?: boolean;
}
export function CustomBarChart({
@ -28,16 +29,17 @@ export function CustomBarChart({
showAxisLabels = true,
className = "",
loading = false,
hasPercent = true,
}: CustomBarChartProps) {
// Calculate the maximum value across all data points for consistent scaling
const globalMaxValue = Math.max(
...data.map((item) => item.maxValue || item.value)
);
// استفاده از nice numbers برای محاسبه دامنه مناسب
const values = data.map((item) => item.maxValue || item.value);
const { niceMax, ticks } = calculateNiceRange(values, 0, 5);
const globalMaxValue = niceMax;
// Loading skeleton
if (loading) {
return (
<div className={`space-y-6 p-4 ${className}`} style={{ height }}>
<div className={`space-y-6 p-4 pt-0 ${className}`} style={{ height }}>
{title && (
<div className="h-7 bg-gray-600 rounded animate-pulse mb-4 w-1/2"></div>
)}
@ -67,92 +69,67 @@ export function CustomBarChart({
return (
<div className={`space-y-6 ${className}`} style={{ height }}>
<div className="border-b">
{title && (
<h3 className="text-xl font-bold text-white font-persian text-right p-4">
{title && (
<div className="border-b-[#3F415A] border-b-2">
<h3 className="text-sm font-semibold text-white font-persian text-right px-4 pb-3">
{title}
</h3>
)}
</div>
</div>
)}
<div className="space-y-4 px-4 pb-4">
{data.map((item, index) => {
// محاسبه درصد بر اساس nice max value
const percentage =
globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0;
const displayValue: any = item.value;
return (
<div key={index} className="flex items-center gap-3">
{/* Label */}
<span
className={`font-persian text-sm min-w-[160px] text-right ${
className={`font-persian text-sm font-normal min-w-[120px] text-left ${
item.labelColor || "text-white"
}`}
>
{item.label}
</span>
{/* Bar Container */}
<div
className={`flex-1 flex items-center bg-gray-700 rounded-full relative overflow-hidden ${barHeight}`}
className={`${showAxisLabels && "bg-pr-gray"} flex-1 flex items-center gap-1 justify-start rounded-full overflow-hidden ${barHeight}`}
>
<div
className={`${barHeight} rounded-full transition-all duration-700 ease-out relative ${
item.color || "bg-emerald-400"
className={`${barHeight} rounded-full transition-all duration-700 ease-out ${
item.color || "bg-pr-green"
}`}
style={{
width: `${Math.min(percentage, 100)}%`,
}}
>
{/* Add a subtle gradient effect for better visual appeal */}
<div className="absolute 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>
{/* Value Label */}
<span
className={`font-bold text-sm min-w-[60px] text-left ${
item.color?.includes("emerald")
? "text-emerald-400"
: item.color?.includes("blue")
? "text-blue-400"
: item.color?.includes("purple")
? "text-purple-400"
: item.color?.includes("red")
? "text-red-400"
: item.color?.includes("yellow")
? "text-yellow-400"
: "text-emerald-400"
}`}
>
{item.valuePrefix || ""}
{formatNumber(parseFloat(displayValue))}%
{item.valueSuffix || ""}
</span>
<span className={`text-base font-normal text-left text-white`}>
{item.valuePrefix || ""}
{formatNumber(parseFloat(displayValue))}
{hasPercent ? "%" : ""}
{item.valueSuffix || ""}
</span>
</div>
</div>
);
})}
{/* Axis Labels */}
{/* Axis Labels با استفاده از nice numbers */}
{showAxisLabels && globalMaxValue > 0 && (
<div className="flex items-center gap-3 mt-6">
<span className="min-w-[160px]"></span>
<div className="flex w-full items-center gap-3 mt-6">
<span className="min-w-[120px]"></span>
<div className="flex-1 flex justify-between pt-2 border-t border-gray-700">
<span className="text-gray-400 text-xs">{formatNumber(0)}</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(globalMaxValue / 4))}
</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>
{ticks.map((tick, index) => (
<span key={index} className="text-gray-400 text-xs">
{formatNumber(tick)}%
</span>
))}
</div>
<span className="min-w-[60px]"></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 { X } from "lucide-react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
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<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@ -59,13 +59,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
@ -78,8 +78,8 @@ const DialogFooter = ({
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,18 +105,18 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
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 { Check, ChevronRight, Circle } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronDown, 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<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
@ -34,11 +34,10 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -52,9 +51,9 @@ const DropdownMenuSubContent = React.forwardRef<
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +64,34 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<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
ref={ref}
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",
selected && "bg-dark-blue text-white",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -112,9 +113,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -135,13 +136,13 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
@ -153,8 +154,8 @@ const DropdownMenuLabel = React.forwardRef<
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -165,8 +166,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -177,24 +178,43 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...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 {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuButton,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
DropdownMenuTrigger,
};

View File

@ -13,6 +13,7 @@ interface FunnelChartProps {
title?: string;
className?: string;
}
const greenColors = ["#3C9F71","#3BC47A","#3BC47A","#3BD77E","#3AEA83"]
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
const maxValue = Math.max(...data.map(d => d.value));
@ -20,22 +21,22 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
if (!maxValue || maxValue <= 0) return 0;
return Math.round((value / maxValue) * 100);
};
return (
<div className={`w-full ${className}`}>
{title && (
<h3 className="text-lg font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
<h3 className="text-sm px-4 font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
{title}
</h3>
)}
<div className="flex flex-col items-center gap-2 space-y-2">
<div className="flex px-4 flex-col items-center gap-2 space-y-2">
{/* Start Process Line */}
<div className="flex items-center w-full gap-10 mt-6">
<div className="text-lg text-gray-600 min-w-[max-content]">ابتدا فرآیند</div>
<div className="flex items-center w-full gap-10 mt-6 px-4">
<div className="text-sm font-normal text-[#5F6284] min-w-[max-content]">ابتدا فرآیند</div>
<div className="flex items-center w-full gap-4">
<div className="w-full h-0.5 bg-gray-600 relative">
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 top-[-1rem] -translate-y-1/2">۱۰۰%</div>
<div className="text-base text-white font-semibold absolute left-1/2 -translate-x-1/2 top-[-1rem] -translate-y-1/2">۱۰۰%</div>
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
</div>
@ -47,20 +48,20 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
{data.map((item, index) => {
const widthPercentage = toPercent(item.value);
const barWidth = Math.max(20, widthPercentage); // Minimum 20% width
return (
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
<div className="text-lg text-white cols-start-1 justify-self-start font-thin min-w-[max-content] text-center">
<div className="text-sm font-light text-white font-persian cols-start-1 justify-self-start min-w-[max-content] text-center">
{item.label}
</div>
<div className="flex items-center gap-10 w-full cols-start-2 flex items-center justify-center w-full">
<div className="flex items-center gap-10 w-full cols-start-2 justify-center">
<div className="flex items-center w-full">
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
<div
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
style={{ width: `${barWidth}%` }}
style={{ width: `${barWidth}%` ,backgroundColor : `${greenColors[index]}`}}
>
<span className="text-[#3F415A] font-semibold">
<span className="text-pr-gray text-base font-semibold">
{item.value.toLocaleString('fa-IR')}
</span>
</div>
@ -73,15 +74,15 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
</div>
{/* End Process Line */}
<div className="flex items-center w-full gap-10">
<div className="text-lg text-gray-600 min-w-[max-content]">انتها فرآیند</div>
<div className="flex items-center w-full gap-10 px-4">
<div className="text-sm text-[#5F6284] min-w-[max-content]">انتها فرآیند</div>
<div className="flex items-center w-full gap-4">
{(() => {
const lastValue = data[data.length - 1]?.value ?? 0;
const percent = toPercent(lastValue);
return (
<div style={{ width: `${percent}%` }} className={`mx-auto h-0.5 bg-gray-600 relative ${percent === 0 ? "hidden" : ""}`}>
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 bottom-[-2.5rem] -translate-y-1">{formatNumber(percent)}%</div>
<div className="text-base font-semibold text-white absolute left-1/2 -translate-x-1/2 bottom-[-2.5rem] -translate-y-1">{formatNumber(percent)}%</div>
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
</div>

View File

@ -17,32 +17,32 @@ export function MetricCard({
percentLabel = "درصد به کل",
}: MetricCardProps) {
return (
<BaseCard title={title}>
<BaseCard title={title} className="h-full">
<div className="flex items-center justify-center flex-col">
<div className="flex items-center gap-4">
<div className="text-center">
<p className="text-3xl font-bold text-green-400">
{formatNumber(value)}
</p>
<div className="text-xs text-gray-400 font-persian">
{valueLabel}
</div>
</div>
{percentValue !== undefined && (
<>
<span className="text-5xl font-thin text-gray-600">/</span>
<div className="text-center">
<p className="text-3xl font-bold text-green-400">
{formatNumber(percentValue)}%
</p>
<div className="text-xs text-gray-400 font-persian">
{percentLabel}
</div>
</div>
</>
)}
<div className="flex items-center gap-4 h-full">
<div className="text-center">
<p className="text-3xl font-bold text-green-400">
{formatNumber(value)}
</p>
<div className="text-xs text-gray-400 font-persian">
{valueLabel}
</div>
</div>
</BaseCard>
{percentValue !== undefined && (
<>
<span className="text-5xl font-thin text-gray-600">/</span>
<div className="text-center">
<p className="text-3xl font-bold text-green-400">
{formatNumber(percentValue)}%
</p>
<div className="text-xs text-gray-400 font-persian">
{percentLabel}
</div>
</div>
</>
)}
</div>
</div>
</BaseCard>
);
}
}

View File

@ -1,26 +1,51 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "~/lib/utils"
import { cn, formatNumber } from "~/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
>(({ 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
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-pr-gray",
className
)}
{...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
className="h-full w-full flex-1 bg-pr-green transition-all z-20"
style={{ transform: `translateX(-${100-scaledValue}%)` }}
/>
</ProgressPrimitive.Root>
)
})
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

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

View File

@ -81,7 +81,7 @@ export function TabsTrigger({
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",
isActive
? "bg-gray-700 text-foreground shadow-sm"
? "bg-pr-gray text-foreground shadow-sm"
: "hover:bg-muted/50",
className,
)}

View File

@ -52,7 +52,7 @@ function TooltipContent({
{...props}
>
{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.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
public async call<T = any>(payload: any) {
//بندر امام
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);
}
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
public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {

View File

@ -1,6 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import EventEmitter from "events";
import moment from "moment-jalaali";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@ -22,10 +23,100 @@ export const formatCurrency = (amount: string | number) => {
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);
export const handleDataValue = (val: any): any => {
moment.loadPersian({ usePersianDigits: true });
// اگر همه مقادیر صفر یا منفی هستند
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 => {
moment.loadPersian({ usePersianDigits: true });
if (val == null) return val;
if (
typeof val === "string" &&
@ -40,4 +131,6 @@ moment.loadPersian({ usePersianDigits: true });
return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
}
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 { ProtectedRoute } from "~/components/auth/protected-route";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} 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 moment from "moment-jalaali";
import type { Route } from "./+types/ecosystem";
// Get API base URL at module level to avoid process.env access in browser
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 { Hexagon } from "lucide-react";
import type { CompanyDetails } from "~/components/ecosystem/network-graph";
import { formatNumber } from "~/lib/utils";
export function meta({}: Route.MetaArgs) {
return [
@ -55,10 +61,20 @@ function handleValue(val: any): any {
export default function EcosystemPage() {
const [selectedCompany, setSelectedCompany] =
React.useState<CompanyDetails | null>(null);
const [isDialogLoading, setIsDialogLoading] = React.useState(false);
const { token } = useAuth();
const closeDialog = () => {
setSelectedCompany(null);
setIsDialogLoading(false);
};
const handleNodeClick = (company: CompanyDetails) => {
setSelectedCompany(company);
};
const handleLoadingChange = (loading: boolean) => {
setIsDialogLoading(loading);
};
// Construct image URL
@ -69,16 +85,19 @@ export default function EcosystemPage() {
return (
<ProtectedRoute requireAuth={true}>
<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="lg:col-span-4">
<InfoPanel selectedCompany={selectedCompany} />
</div>
<div className="lg:col-span-8 h-full">
<Card className="h-full overflow-hidden">
<CardContent className="p-0 h-full">
<NetworkGraph onNodeClick={setSelectedCompany} />
<Card className="h-full overflow-hidden bg-transparent border-[#3F415A]">
<CardContent className="p-0 h-full bg-transparent">
<NetworkGraph
onNodeClick={handleNodeClick}
onLoadingChange={handleLoadingChange}
/>
</CardContent>
</Card>
</div>
@ -90,104 +109,146 @@ export default function EcosystemPage() {
open={!!selectedCompany}
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>
<DialogTitle className="text-right border-b-2 border-gray-600 py-2 mr-4 text-xl">
<DialogTitle className="text-right border-b-2 border-gray-600 pt-2 pb-4 mr-4 text-sm font-semibold">
معرفی
<span> {selectedCompany?.category}</span>
</DialogTitle>
<DialogDescription className="text-center text-green-400"></DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Right Column - Description */}
<div className="space-y-4 p-6 border-l-2 border-gray-600">
{/* Company Image */}
<div className="flex justify-between px-10 items-center text-3xl font-bold mb-4">
{selectedCompany?.label || ""}
{selectedCompany?.stageid && token?.accessToken ? (
<img
src={getImageUrl(selectedCompany.stageid)}
alt={selectedCompany?.label || ""}
className="w-14 h-14 object-cover rounded-2xl"
onError={(e) => {
// Hide image and show fallback on error
e.currentTarget.style.display = "none";
if (e.currentTarget.nextSibling) {
(
e.currentTarget.nextSibling as HTMLElement
).style.display = "flex";
}
}}
/>
) : null}
<div
className="w-24 h-24 rounded-full bg-gray-600 border-4 border-green-400 flex items-center justify-center"
style={{
display:
selectedCompany?.stageid && token?.accessToken
? "none"
: "flex",
}}
>
<svg
className="w-10 h-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
{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>
{selectedCompany?.description ? (
<div className="p-4 rounded-lg">
<p className="font-persian leading-relaxed">
{selectedCompany.description}
</p>
</div>
) : (
<div className="text-gray-500 font-persian text-sm">
توضیحات در دسترس نیست
</div>
)}
</div>
{/* Left Column - Company Fields */}
<div className="space-y-2">
<h3 className="font-persian gap-1 flex text-lg font-bold">
اطلاعات
<span>{selectedCompany?.category}</span>
</h3>
{selectedCompany?.fields &&
selectedCompany.fields.length > 0 ? (
<div className="space-y-3">
{selectedCompany.fields.map((field, index) => (
{/* 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"
>
<span className="font-persian font-light">
{field.N}:
</span>
<span className="font-persian font-light text-right">
{handleValue(field.V)}
{field.U && <span className="mr-1">({field.U})</span>}
</span>
<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 className="text-gray-500 font-persian text-sm">
اطلاعات تکمیلی در دسترس نیست
</div>
)}
</div>
</div>
</div>
) : (
<div className="grid p-4 pb-6 grid-cols-1 md:grid-cols-2 gap-6">
{/* Right Column - Description */}
<div className="space-y-4 p-6 border-l-2 border-gray-600">
{/* Company Image */}
<div className="flex justify-between px-10 items-center text-3xl font-bold mb-4">
{selectedCompany?.label || ""}
{selectedCompany?.stageid && token?.accessToken ? (
<img
src={getImageUrl(selectedCompany.stageid)}
alt={selectedCompany?.label || ""}
className="w-12 h-12 object-cover rounded-2xl"
onError={(e) => {
// Hide image and show fallback on error
e.currentTarget.style.display = "none";
if (e.currentTarget.nextSibling) {
(
e.currentTarget.nextSibling as HTMLElement
).style.display = "flex";
}
}}
/>
) : null}
<div
className="w-24 h-24 rounded-full bg-gray-600 border-4 border-pr-green flex items-center justify-center"
style={{
display:
selectedCompany?.stageid && token?.accessToken
? "none"
: "flex",
}}
>
<svg
className="w-10 h-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
</div>
{selectedCompany?.description ? (
<div className="p-4 rounded-lg">
<p className="font-persian text-sm font-normal leading-relaxed">
{selectedCompany.description}
</p>
</div>
) : (
<div className="text-gray-500 font-persian text-sm">
توضیحات در دسترس نیست
</div>
)}
</div>
{/* Left Column - Company Fields */}
<div className="space-y-2">
<h3 className="font-persian gap-1 flex text-sm font-semibold">
اطلاعات
<span>{selectedCompany?.category}</span>
</h3>
{selectedCompany?.fields &&
selectedCompany.fields.length > 0 ? (
<div className="space-y-3 px-2">
{selectedCompany.fields.map((field, index) => (
<div
key={index}
className="flex justify-between items-center rounded-lg"
>
<span className="font-persian flex items-center gap-1 text-sm font-light">
<Hexagon className="text-pr-green h-4 w-4" />
{field.N}:
</span>
<span className="text-right min-w-1/3">
<span className="font-persian text-sm font-normal text-right">
{handleValue(field.V)}
{field.U && (
<span className="mr-1">({field.U})</span>
)}
</span>
</span>
</div>
))}
</div>
) : (
<div className="text-gray-500 font-persian text-sm">
اطلاعات تکمیلی در دسترس نیست
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</DashboardLayout>

View File

@ -3,7 +3,7 @@ import { ManageIdeasTechPage } from "~/components/dashboard/project-management/m
export function meta() {
return [
{ title: "مدیریت فنواری و ایده ها" },
{ title: "مدیریت فناوری و ایده ها" },
{ 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",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"file-saver": "^2.0.5",
"graphology": "^0.26.0",
"isbot": "^5.1.27",
"lucide-react": "^0.525.0",
@ -35,11 +36,13 @@
"react-hot-toast": "^2.5.2",
"react-router": "^7.7.0",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@react-router/dev": "^7.7.0",
"@tailwindcss/vite": "^4.1.4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

426
public/font/IranYekanX.html Normal file
View File

@ -0,0 +1,426 @@
<!doctype html>
<html lang="fa">
<head>
<meta charset="utf-8">
<title>IRANYekanX Family Type face: خانواده فونت ایران‌سنس</title>
<meta name="fontiran.com:license" content="کد ۵ رقمی لایسنس">
<link href="style.css" rel="stylesheet">
</head>
<body dir="rtl">
<div class="wrapper">
<div class="mainbox">
<div class="titelbox">
<h1>بِسْمِ اللهِ الرَّحْمَنِ الرَّحِيمِ</h1>
</div>
<div class="alphabet" style="line-height:200px" > د </div>
<div class="rightbox">
<br>
<span class="text-xlarge">ن وَالْقَلَمِ وَ مَا يَسْطُرُون</span>
<br>
<span class="text-xlarge"> نون؛ سوگند به قلم و آنچه می نويسند. </span>
<br>
<span class="text-large"> Noon. I swear by the pen and what the angels write </span>
<br>
<br>
</div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1>نکاتی درباره تایپوگرافی وب</h1>
</div>
<div class="alphabet" style="line-height:250px" > ج </div>
<div class="rightbox">
<p>از زمان پیدایش نخستین وب سایت، متن ها یکی از اجزای مهم صفحات وب بودند. هر چند به مرور زمان با ورود تصاویر، صوت و فیلم کمی از بار مسئولیت متون کم شد اما هنوز جایگاه خود را از دست نداده اند و بخش مهمی از کار را به عهده دارند.</p>
<p>بسیاری از طراحان وب سایت به صورت تجربی بهترین ترکیب و ظاهر را برای نمایش متن ها انتخاب می کنند. اما اصولی وجود دارد که با رعایت آن ها، تاثیرپذیری و زیبایی سایت چند برابر خواهد شد.</p>
<p>در ادامه مطلب قصد داریم تعدادی از اصول مقدماتی تایپوگرافی را به اختصار مرور کنیم. هرچند بسیاری از دوستان با این نکات آشنا هستند؛ اما شاید مرور آن ها خالی از لطف نباشد.</p>
</div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1>تاثیر اندازه فونت و سلسله مراتب تگ‌ها</h1>
</div>
<div class="alphabet" style="line-height:170px"> ن </div>
<div class="rightbox">
<p>فونت های داخل سایت باید به گونه ای قرار گیرند که کاربر به راحتی بتواند آن ها را بخواند. نوع فونت، وزن و کوچک (یا بزرگ) بودن اندازه آن ممکن است تمایل کاربر برای بازگشت به وب سایت را کاهش دهد. قواعد و قوانین زیادی برای انتخاب بهترین فونت وجود دارد.</p>
<p>در زیر می توانید ۱۲ وزن مختلف <strong>خانواده فونت ایران‌سنس</strong> را در شرایط یکسان مشاهده کنید. لازم به ذکر است برای این صفحه از وزن معمولی (Normal) استفاده شده است.</p>
</div>
</div>
<div class="mainbox">
<div class="mainbox2">
<div class="text-thin" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Thin)</div>
<div class="text-UltraLight" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (UltraLight)</div>
<div class="text-light" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Light)</div>
<div class="text-regular" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Regular)</div>
<div class="text-medium" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Medium)</div>
<div class="text-demibold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (demiBold)</div>
<div class="text-bold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Bold)</div>
<div class="text-extrabold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (ExtraBold)</div>
<div class="text-black" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Black)</div>
<div class="text-extrablack" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (ExtraBlack)</div>
<div class="text-heavy" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Heavy)</div>
</div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2">
<div class="text-thin" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Thin)</div>
<div class="text-UltraLight" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (UltraLight)</div>
<div class="text-light" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Light)</div>
<div class="text-regular" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Regular)</div>
<div class="text-medium" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Medium)</div>
<div class="text-demibold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (demiBold)</div>
<div class="text-bold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Bold)</div>
<div class="text-extrabold" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (ExtraBold)</div>
<div class="text-black" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Black)</div>
<div class="text-extrablack" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (ExtraBlack)</div>
<div class="text-heavy" style="font-size:2.2em">من نه آنم که زبونی کشم از چرخ فلک (Heavy)</div>
</div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1>تگ‌های هدینگ</h1>
</div>
<div class="alphabet" style="line-height:180px"> و </div>
<div class="rightbox">
<p>در این بین، استفاده از تگ های هدینگ مناسب و رعایت سلسله مراتب آن ها، هم مفهوم نوشته را بهتر منتقل می کند و هم تاثیر قابل توجهی در نتایج موتورهای جستجو خواهد داشت. این نکته یکی از فاکتورهای مهم در بهینه سازی وب سایت برای موتورهای جستجو (Search Engine Optimization) است.</p>
<p>نمونه ای از خروجی تگ های هدینگ با سایز استاندارد مربوطه به فونت های فونت ایران‌سنس را در زیر مشاهده می کنید.</p>
</div>
</div>
<div class="mainbox">
<div class="mainbox2">
<h1>(H1) نابرده رنج گنج میسر نمی شود. No gain without pain </h1>
<h2>(H2) نابرده رنج گنج میسر نمی شود. No gain without pain</h2>
<h3>(H3) نابرده رنج گنج میسر نمی شود. No gain without pain</h3>
<h4>(H4) نابرده رنج گنج میسر نمی شود. No gain without pain </h4>
<h5>(H5) نابرده رنج گنج میسر نمی شود. No gain without pain</h5>
<h6>(H6) نابرده رنج گنج میسر نمی شود. No gain without pain</h6>
</div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2">
<h1>(H1) نابرده رنج گنج میسر نمی شود. No gain without pain </h1>
<h2>(H2) نابرده رنج گنج میسر نمی شود. No gain without pain</h2>
<h3>(H3) نابرده رنج گنج میسر نمی شود. No gain without pain</h3>
<h4>(H4) نابرده رنج گنج میسر نمی شود. No gain without pain </h4>
<h5>(H5) نابرده رنج گنج میسر نمی شود. No gain without pain</h5>
<h6>(H6) نابرده رنج گنج میسر نمی شود. No gain without pain</h6>
</div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1>سطر و ستون بندی</h1>
</div>
<div class="alphabet" style="line-height:200px"> م </div>
<div class="rightbox">
<p>معمولاً هرگاه مقدار نوشته از یک سطر بیشتر شود ناگزیر به ستون بندی هستیم. و این کار ما را با چند متغییر مواجه خواهد کرد:</p>
<p>۱- <strong>عرض ستون‌های متنی</strong> که حاوی حداقل۷ کلمه باشد بهترین انتخاب است. اگر ستون کوتاه‌تر باشد چشم بیننده در اثر حرکت‌های زود به زود از پایان یک سطر به ابتدای سطر بعدی خسته خواهد شد. علاوه بر این لبه ستون‌هایی با عرض کم نیز همیشه دندانه ای و بی نظم خواهد بود. ستون‌هایی با عرض طولانی هم برای خواننده آزار دهنده است چرا که چشم در حرکت بازگشت از انتهای یک سطر به ابتدای سطر بعدی ممکن است دچار اشتباه شود.
<p>۲- <strong>لدینگ یا همان فاصله سطر </strong> هم در ستون بندی اهمیت دارد. ستون‌های فشرده اگرچه به لحاظ گرافیکی منسجم و زیبا هستند اما عمل خواندن را مختل می کنند و در مقابل، فاصله سطر زیاد نیز باعث نازیبایی و خستگی چشم خواننده می‌شود. فاصله سطر همیشه می تواند با توجه به نوع فونت و عرض ستون‌ها تغییر کند. بدین ترتیب که فونت‌هایی با دندانه‌های بلند‌تر و ستون‌هایی با عرض بیشتر به لدینگ بیشتری نیاز دارند.
<p>۳- <strong>همترازی </strong> هم یکی از متغییرهای پر بحث در ستون بندی است. اما به طور کوتاه و خلاصه باید گفت که بهتر است در متن فارسی از همترازی یا همان Justification استفاده نکنید. این عمل اگرچه لبه پاراگراف شما را مرتب خواهد کرد اما تنظیمات فاصله حروف را تغییر خواهد داد و در نتیجه باعث کاهش خوانایی خواهد شد. </div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Thin)</div>
<div class="farsiparagraph"><span class="text-thin">ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-thin">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX UltraLight)</div>
<div class="farsiparagraph"><span class="text-UltraLight">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-UltraLight">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Light)</div>
<div class="farsiparagraph"><span class="text-light">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-light">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Regular)</div>
<div class="farsiparagraph"><span class="text-regular">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-regular">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Mediume)</div>
<div class="farsiparagraph"><span class="text-medium">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-medium">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX DemiBold)</div>
<div class="farsiparagraph"><span class="text-demibold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-demibold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Bold)</div>
<div class="farsiparagraph"><span class="text-bold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-bold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX ExtraBold)</div>
<div class="farsiparagraph"><span class="text-extrabold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-extrabold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Black)</div>
<div class="farsiparagraph"><span class="text-black">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-black">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX ExtraBlack)</div>
<div class="farsiparagraph"><span class="text-extrablack">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-extrablack">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="mainbox2negativ">(IRANYekanX Heavy)</div>
<div class="farsiparagraph"><span class="text-heavy">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-heavy">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Thin)</div>
<div class="farsiparagraph"><span class="text-thin">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-thin">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX UltraLight)</div>
<div class="farsiparagraph"><span class="text-UltraLight">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-UltraLight">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Light)</div>
<div class="farsiparagraph"><span class="text-light">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-light">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Regular)</div>
<div class="farsiparagraph"><span class="text-regular">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-regular">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Mediume)</div>
<div class="farsiparagraph"><span class="text-medium">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-medium">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX DemiBold)</div>
<div class="farsiparagraph"><span class="text-demibold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-demibold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Bold)</div>
<div class="farsiparagraph"><span class="text-bold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-bold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX ExtraBold)</div>
<div class="farsiparagraph"><span class="text-extrabold">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-extrabold">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Black)</div>
<div class="farsiparagraph"><span class="text-black">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-black">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX ExtraBlack)</div>
<div class="farsiparagraph"><span class="text-extrablack">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-extrablack">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainboxnegativ">
<div class="mainbox2negativ">(IRANYekanX Heavy)</div>
<div class="farsiparagraph"><span class="text-heavy">
ایران‌یکان IRANYekanX از ترکیب دو فونت پر طرفدار یکان و ایران‌سنس پدید آمده است. بخشی از منحنی‌های ایران‌سنس به ساختار عمودی و افقی یکان اضافه شده است تا ایران‌یکان IRANYekanX فونتی باشد که با وجود هندسی بودن خشک و مکانیکی نباشد. این فونت با سبک‌های طراحی کمینه‌گرا Minimal سازگاری خوبی دارد و همنشین مناسبی برای فونت‌های سن‌سریف لاتین است. </span></div>
<div class="englishparagraph"><span class="text-heavy">
Roboto has a dual nature. It has a mechanical skeleton and the forms are largely geometric. At the same time, the font features friendly and open curves. While some grotesks distort their letterforms to force a rigid rhythm, Roboto doesnt compromise, allowing letters to be settled into their natural width. This makes for a more natural reading rhythm more commonly found in humanist and serif types.</span></div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1>اعداد و علائم در فونت فارسی</h1>
</div>
<div class="alphabet" style="line-height:470px"> ظ </div>
<div class="rightbox">
<p><span class="text-xlarge">اعداد فارسی: <strong>۱۲۳۴۵۶۷۸۹۰</strong></span><br>
<span class="text-xlarge">اعداد عربی: <strong>۱۲۳٤٥٦۷۸۹۰</strong></span><br>
<span class="text-xlarge">اعداد انگلیسی: <strong>1234567890</strong></span></p>
<p>برای تایپ اعداد فارسی در محیط وب از <a href="http://fontiran.com/%d9%86%d8%b5%d8%a8-%da%a9%db%8c%d8%a8%d9%88%d8%b1%d8%af-%d9%81%d8%a7%d8%b1%d8%b3%db%8c-%d8%a7%d8%b3%d8%aa%d8%a7%d9%86%d8%af%d8%a7%d8%b1%d8%af-%d8%af%d8%b1-%d9%88%db%8c%d9%86%d8%af%d9%88%d8%b28-%d9%88/">کیبورد استاندارد فارسی</a> استفاده کنید. در ویندوز ۸ و یا بالاتر این کیبورد، با نام Persian(Standard)Keyboard در لیست کیبوردهای ویندوز وجود دارد. همچنین می توانید از <a href="http://persian-computing.ir/download/Iranian_Standard_Persian_Keyboard_(ISIRI_9147)_(Version_2.0).zip">این آدرس</a> آن را دانلود و نصب کنید.</p>
<span>
<p>با استفاده از کیبورد استاندارد می‌توانید ممیز فارسی را تایپ کنید.<br>
<span>میانبر این علامت کلیدهای <span class="text-medium">Shift+3</span> است. به این شکل: <strong class="text-xlarge">۳٫۱۴</strong></span><br>
<span >ممیز فارسی با علامت اسلش تفاوت دارد :<strong class="text-xlarge">۳/۱۴</strong></span>
</p>
<p>با استفاده از کیبورد استاندارد می‌توانید جداکننده هزارگان فارسی را تایپ کنید.<br>
<span>میانبر این علامت کلیدهای <strong>Shift+2</strong> است. به این شکل: <strong class="text-xlarge">۹٬۲۱۰٬۰۰۰</strong></span><br>
<span >این علامت با جدا کننده هزارگان انگلیسی تفاوت دارد : <strong class="text-xlarge">۹,۲۱۰,۰۰۰</strong></span>
</p>
</div>
</div>
<div class="mainbox">
<div class="titelbox" style="letter-spacing: 0px">
<h1> نسبت‌های طلایی</h1>
</div>
<div class="alphabet2">
<div style="padding-left:20px">
۳٫۷۷۷ &divide; ۱٫۶۱۸ = <span class="text-regular text-underline">۲٫۳۳۵</span>
<br>
۶٫۱۱۲ &divide; ۱٫۶۱۸ = <span class="text-regular text-underline">۳٫۷۷۷</span>
<br>
۹٫۸۸۹ &divide; ۱٫۶۱۸ = <span class="text-regular text-underline">۶٫۱۱۲</span>
<br>
۱۶ &divide; ۱٫۶۱۸ = <span class="text-regular text-underline">۹٫۸۸۹</span>
<br>
.......................................
<br>
۱۶ &times; ۱٫۶۱۸ = <span class="text-regular text-underline">۲۵٫۸۸۸</span>
<br>
۲۵٫۸۸۸ &times; ۱٫۶۱۸ = <span class="text-regular text-underline">۴۱٫۸۸۷</span>
<br>
۴۱٫۸۸۷ &times; ۱٫۶۱۸ = <span class="text-regular text-underline">۶۷٫۷۷۳</span>
<br>
۶۷٫۷۷۳ &times; ۱٫۶۱۸ = <span class="text-regular text-underline">۱۰۹٫۶۵۶</span>
</div>
</div>
<div class="rightbox">
<p>اهمیت اندازه فونت در خوانایی و زیبا شدن صفحه وب سایت بر کسی پوشیده نیست. در کنار این بحث، موارد دیگری مانند ارتفاع خطوط، فاصله ها، ابعاد قسمت های مختلف و ... نیز در بحث تایپوگرافی اهمیت زیادی دارند.</p>
<p>برای محاسبه این اعداد می توانیم از سری اعداد متناسب (Modular Scale) استفاده کنیم. در حقیقت از تعدادی عدد پشت سر هم که بر اساس مضرب خاصی تشکیل شده اند برای تنظیمات ارتفاع خط، فاصله ها، ابعاد و ... استفاده می کنیم. نسبت (عدد) طلایی همان مضرب اعداد است.</p>
<p>به عنوان مثال می خواهیم از سایز 16px <strong>فونت ایران‌سنس</strong> به عنوان فونت و سایز اصلی متن صفحات استفاده کنیم. عدد فی (phi) یونانی که معادل ۱٫۶۱۸۰۳۳۹۸۸۷ (به اختصار ۱٫۶۱۸) است را به عنوان نسبت طلایی در نظر می گیریم. بنابراین سری اعدادی به شکل روبرو خواهیم داشت:</p>
<p>
به کمک این اعداد و استفاده از آن ها در صفحات وب سایت خود می توانیم خوانایی و زیبایی آن را افزایش دهیم. علاوه بر آن، اگر از واحدهای نسبی مانند em استفاده شود، امکانات بیشتری در اختیار طراح و بازدیدکننده خواهد بود. البته <strong>سری اعداد بر مبنای نسبت طلایی</strong> فقط در تایپوگرافی وب سایت کاربرد ندارد.
این اعداد می تواند ادامه داشته باشد (<span class="text-regular text-underline">۱٬۲۱۵٫۹۸۱</span> ، <span class="text-regular text-underline">۱٬۹۶۷٫۴۵۷</span> ، <span class="text-regular text-underline">۳٬۱۸۳٫۳۴۵</span> و ...).
</p>
</div>
</div>
<div class="mainbox">
<div class="rightbox">
<br>
در این فایل سعی کردیم همراه با یک مطلب آموزشی کوتاه، نحوه استفاده از خانواده فونت ایران‌سنس و پیش نمایشی از قسمت های مختلف آن را مرور کنیم.
<br>
برای مشاهده راهنمای نحوه قراردادن فونت ها در وب سایت خود، به <a href="http://fontiran.com/%d9%86%d8%b5%d8%a8-%d9%81%d9%88%d9%86%d8%aa-%d8%a7%db%8c%d8%b1%d8%a7%d9%86-%d8%b3%d9%86%d8%b3-iransans-%d8%b1%d9%88%db%8c-%d9%88%d8%a8%d8%b3%d8%a7%db%8c%d8%aa/" target="_blank">این آدرس</a> مراجعه کنید.
<br>
<br>
<br>
<br>
<br>
</div>
<div class="alphabet" style="line-height:180px">ء</div>
</div>
<br>
<div class="footer">
Copyright (c) 2021 by <a href="http://fontiran.com">www.fontiran.com</a> (Moslem Ebrahimi). All rights reserved.
<br>
To use this font, it is necessary to obtain the license from www.fontiran.com
</div>
</div>
</body>
</html>

116
public/font/fontiran.css Normal file
View File

@ -0,0 +1,116 @@
/**
*
* Name: IRANYekanX Fonts
* Version: 2.4
* Author: Moslem Ebrahimi (moslemebrahimi.com)
* Created on: Aug 02, 2022
* Updated on: Aug 02, 2022
* Website: http://fontiran.com
* Copyright: Commercial/Proprietary Software
--------------------------------------------------------------------------------------
فونت ایران یکان X یک نرم افزار مالکیتی محسوب می شود. جهت آگاهی از قوانین استفاده از این فونت ها لطفا به وب سایت (فونت ایران دات کام) مراجعه نمایید
--------------------------------------------------------------------------------------
IRANYekanX fonts are considered a proprietary software. To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com
--------------------------------------------------------------------------------------
This set of fonts are used in this project under the license: (.....)
------------------------------------------------------------------------------------- fonts/-
*
**/
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 100;
src:
url("/font/woff/IRANYekanX-Thin.woff") format("woff"),
url("/font/woff2/IRANYekanX-Thin.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 200;
src:
url("/font/woff/IRANYekanX-UltraLight.woff") format("woff"),
url("/font/woff2/IRANYekanX-UltraLight.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 300;
src:
url("/font/woff/IRANYekanX-Light.woff") format("woff"),
url("/font/woff2/IRANYekanX-Light.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 500;
src:
url("/font/woff/IRANYekanX-Medium.woff") format("woff"),
url("/font/woff2/IRANYekanX-Medium.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 600;
src:
url("/font/woff/IRANYekanX-DemiBold.woff") format("woff"),
url("/font/woff2/IRANYekanX-DemiBold.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 800;
src:
url("/font/woff/IRANYekanX-ExtraBold.woff") format("woff"),
url("/font/woff2/IRANYekanX-ExtraBold.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 900;
src:
url("/font/woff/IRANYekanX-Black.woff") format("woff"),
url("/font/woff2/IRANYekanX-Black.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 950;
src:
url("/font/woff/IRANYekanX-ExtraBlack.woff") format("woff"),
url("/font/woff2/IRANYekanX-ExtraBlack.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: 1000;
src:
url("/font/woff/IRANYekanX-Heavy.woff") format("woff"),
url("/font/woff2/IRANYekanX-Heavy.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: bold;
src:
url("/font/woff/IRANYekanX-Bold.woff") format("woff"),
url("/font/woff2/IRANYekanX-Bold.woff2") format("woff2");
}
@font-face {
font-family: IRANYekanX;
font-style: normal;
font-weight: normal;
src:
url("/font/woff/IRANYekanX-Regular.woff") format("woff"),
url("/font/woff2/IRANYekanX-Regular.woff2") format("woff2");
}

463
public/font/fonttest.html Normal file
View File

@ -0,0 +1,463 @@
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=9" />
<title>IRANYekanX</title>
<style type="text/css" media="screen">
@font-face { font-family: 'WOFF IRANYekanX-Thin'; src: url('woff/IRANYekanX-Thin.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-UltraLight'; src: url('woff/IRANYekanX-UltraLight.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Light'; src: url('woff/IRANYekanX-Light.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Regular'; src: url('woff/IRANYekanX-Regular.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Medium'; src: url('woff/IRANYekanX-Medium.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-DemiBold'; src: url('woff/IRANYekanX-DemiBold.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Bold'; src: url('woff/IRANYekanX-Bold.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-ExtraBold'; src: url('woff/IRANYekanX-ExtraBold.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Black'; src: url('woff/IRANYekanX-Black.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-ExtraBlack'; src: url('woff/IRANYekanX-ExtraBlack.woff'); }
@font-face { font-family: 'WOFF IRANYekanX-Heavy'; src: url('woff/IRANYekanX-Heavy.woff'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Thin'; src: url('woff2/IRANYekanX-Thin.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-UltraLight'; src: url('woff2/IRANYekanX-UltraLight.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Light'; src: url('woff2/IRANYekanX-Light.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Regular'; src: url('woff2/IRANYekanX-Regular.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Medium'; src: url('woff2/IRANYekanX-Medium.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-DemiBold'; src: url('woff2/IRANYekanX-DemiBold.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Bold'; src: url('woff2/IRANYekanX-Bold.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-ExtraBold'; src: url('woff2/IRANYekanX-ExtraBold.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Black'; src: url('woff2/IRANYekanX-Black.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-ExtraBlack'; src: url('woff2/IRANYekanX-ExtraBlack.woff2'); }
@font-face { font-family: 'WOFF2 IRANYekanX-Heavy'; src: url('woff2/IRANYekanX-Heavy.woff2'); }
body {
background: white;
color: black;
}
.features, .label, a, #controls {
font: normal normal normal small sans-serif;
}
.features .emojiButton {
vertical-align: -5%;
font-size: small;
}
.emojiButton {
cursor: pointer;
}
#flexbox {
display: flex;
flex-flow: column;
height: 100%;
}
#controls {
flex: 0 1 auto;
margin: 0;
padding: 0;
width: 100%;
border: 0px solid transparent;
height: auto;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
}
#metricsLine {
background-color: #EEE;
border-top: 1px solid #AAA;
border-bottom: 1px solid #AAA;
width: 100%;
margin: 0.2em 0;
padding: 0 0;
font-size: 2em;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
text-overflow: none;
display: none;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
#metricsLine::-webkit-scrollbar { /* WebKit */
width: 0;
height: 0;
}
#waterfall {
flex: 1 1 auto;
border: 0 solid transparent;
margin: 0;
padding: 0;
width: 100%;
color: black;
overflow-x: hidden;
overflow-y: scroll;
font-family: "WOFF IRANYekanX-Thin";
font-feature-settings: "kern" on, "liga" on, "calt" on;
-moz-font-feature-settings: "kern" on, "liga" on, "calt" on;
-webkit-font-feature-settings: "kern" on, "liga" on, "calt" on;
-ms-font-feature-settings: "kern" on, "liga" on, "calt" on;
-o-font-feature-settings: "kern" on, "liga" on, "calt" on;
}
div, p {
padding: 0;
margin: 0;
}
#waterfall p {
margin-bottom: 0.8em;
overflow-wrap: break-word;
}
.○ .sampletext {
-webkit-text-stroke: 1px black;
-webkit-text-fill-color: #FFF0;
}
.features, .label, a {
color: #888;
}
.label {
background-color: #ddd;
padding: 2px 3px;
}
span#p08 { font-size: 08pt; padding: 08pt 0; }
span#p09 { font-size: 09pt; padding: 09pt 0; }
span#p10 { font-size: 10pt; padding: 10pt 0; }
span#p11 { font-size: 11pt; padding: 11pt 0; }
span#p12 { font-size: 12pt; padding: 12pt 0; }
span#p13 { font-size: 13pt; padding: 13pt 0; }
span#p14 { font-size: 14pt; padding: 14pt 0; }
span#p15 { font-size: 15pt; padding: 15pt 0; }
span#p16 { font-size: 16pt; padding: 16pt 0; }
span#largeParagraph { font-size: 32pt; padding: 32pt 0; }
span#veryLargeParagraph { font-size: 100pt; padding: 100pt 0; }
.otFeatureLabel {
color: #666;
background-color: #ddd;
padding: 0.2em 0.5em 0.3em 0.5em;
margin: 0 .04em;
line-height: 2em;
border-radius: 0.3em;
border: 0;
text-align:center;
}
.otFeatureLabel, .otFeature {
position: relative;
opacity: 1;
pointer-events: auto;
white-space: nowrap;
}
.otFeatureLabel {
padding: 0.2em 0.5em 0.3em 0.5em;
margin: 0 .04em;
line-height: 2em;
color: #666;
background-color: #ddd;
border-radius: 0.3em;
border: 0;
text-align: center;
z-index: 6;
}
.wrapper {
width: auto;
overflow: hidden;
border: 0 solid transparent;
}
select {
float: left;
margin: 0 0.5em 0 0;
padding: 0;
}
input[type=text] {
border: 1px solid #999;
margin: 0;
width: 100%;
}
.features {
clear: left;
}
input[type=checkbox]:checked + label {
visibility: visible;
color: #fff;
background-color: #888;
}
.otFeature {
visibility: collapse;
margin: 0 -1em 0 0;
}
.otFeatureLabel .tooltip {
visibility: hidden;
background-color: #333;
color: white;
text-align: center;
padding: 0px 5px;
top: -2em;
left: 0;
position: absolute;
z-index: 8;
}
.otFeatureLabel:hover .tooltip {
visibility: visible;
}
#featureLine {
display: none;
border-bottom: 1px solid #999;
padding: 0.5em 0;
margin-bottom: 0.5em;
}
/* Footer paragraph: */
#helptext {
color: black;
background-color: #ddd;
position: fixed;
bottom: 0;
padding: 2px
width: 100%;
font: x-small sans-serif;
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
body {
background: #333;
}
.features, .label, a, body, p, #metricsLine {
color: white;
}
.label {
background-color: black;
padding: 2px 3px;
}
.otFeatureLabel, input[type=text] {
color: white;
background-color: black;
}
input[type=checkbox]:checked + label {
color: black;
background-color: #aaa;
}
#helptext {
background-color: #777;
}
.○ .sampletext {
-webkit-text-stroke: 1px white;
-webkit-text-fill-color: #0000;
}
#metricsLine {
background-color: #222;
border-color: #777;
}
}
</style>
</head>
<body onload="document.getElementById('textInput').focus();setCharset();">
<div id="flexbox">
<div id="controls">
<div>
<select size="1" id="fontFamilySelector" name="fontFamilySelector" onchange="changeFont()">
<option value="IRANYekanX-Thin.woff">WOFF IRANYekanX-Thin</option>
<option value="IRANYekanX-UltraLight.woff">WOFF IRANYekanX-UltraLight</option>
<option value="IRANYekanX-Light.woff">WOFF IRANYekanX-Light</option>
<option value="IRANYekanX-Regular.woff">WOFF IRANYekanX-Regular</option>
<option value="IRANYekanX-Medium.woff">WOFF IRANYekanX-Medium</option>
<option value="IRANYekanX-DemiBold.woff">WOFF IRANYekanX-DemiBold</option>
<option value="IRANYekanX-Bold.woff">WOFF IRANYekanX-Bold</option>
<option value="IRANYekanX-ExtraBold.woff">WOFF IRANYekanX-ExtraBold</option>
<option value="IRANYekanX-Black.woff">WOFF IRANYekanX-Black</option>
<option value="IRANYekanX-ExtraBlack.woff">WOFF IRANYekanX-ExtraBlack</option>
<option value="IRANYekanX-Heavy.woff">WOFF IRANYekanX-Heavy</option>
<option value="IRANYekanX-Thin.woff2">WOFF2 IRANYekanX-Thin</option>
<option value="IRANYekanX-UltraLight.woff2">WOFF2 IRANYekanX-UltraLight</option>
<option value="IRANYekanX-Light.woff2">WOFF2 IRANYekanX-Light</option>
<option value="IRANYekanX-Regular.woff2">WOFF2 IRANYekanX-Regular</option>
<option value="IRANYekanX-Medium.woff2">WOFF2 IRANYekanX-Medium</option>
<option value="IRANYekanX-DemiBold.woff2">WOFF2 IRANYekanX-DemiBold</option>
<option value="IRANYekanX-Bold.woff2">WOFF2 IRANYekanX-Bold</option>
<option value="IRANYekanX-ExtraBold.woff2">WOFF2 IRANYekanX-ExtraBold</option>
<option value="IRANYekanX-Black.woff2">WOFF2 IRANYekanX-Black</option>
<option value="IRANYekanX-ExtraBlack.woff2">WOFF2 IRANYekanX-ExtraBlack</option>
<option value="IRANYekanX-Heavy.woff2">WOFF2 IRANYekanX-Heavy</option>
</select>
<div class="wrapper" spellcheck="false">
<input type="text" value="Type Text Here." id="textInput" onclick="this.select();" onkeyup="updateParagraph()" />
</div>
</div>
<p class="features">
<a href="javascript:setCharset();">Charset</a>
<a href="javascript:setLat1();">Lat1</a>
&ensp;
<a href="https://caniuse.com/#feat=woff">woff</a>
<a href="https://caniuse.com/#feat=woff2">woff2</a>
&ensp;
<a onclick="toggleInverse();" id="invert" class="emojiButton">🔲</a>
<label><input type="checkbox" id="kern" value="kern" class="otFeature" onchange="updateFeatures()" checked><label for="kern" class="otFeatureLabel">kern</label>
<label><input type="checkbox" id="liga" value="liga" class="otFeature" onchange="updateFeatures()" checked><label for="liga" class="otFeatureLabel">liga/clig</label>
<label><input type="checkbox" id="calt" value="calt" class="otFeature" onchange="updateFeatures()" checked><label for="calt" class="otFeatureLabel">calt</label>
<input type="checkbox" id="numr" value="numr" class="otFeature" onchange="updateFeatures()"><label for="numr" class="otFeatureLabel">numr</label>
<input type="checkbox" id="dnom" value="dnom" class="otFeature" onchange="updateFeatures()"><label for="dnom" class="otFeatureLabel">dnom</label>
<input type="checkbox" id="frac" value="frac" class="otFeature" onchange="updateFeatures()"><label for="frac" class="otFeatureLabel">frac</label>
<input type="checkbox" id="init" value="init" class="otFeature" onchange="updateFeatures()"><label for="init" class="otFeatureLabel">init</label>
<input type="checkbox" id="medi" value="medi" class="otFeature" onchange="updateFeatures()"><label for="medi" class="otFeatureLabel">medi</label>
<input type="checkbox" id="fina" value="fina" class="otFeature" onchange="updateFeatures()"><label for="fina" class="otFeatureLabel">fina</label>
<input type="checkbox" id="rlig" value="rlig" class="otFeature" onchange="updateFeatures()"><label for="rlig" class="otFeatureLabel">rlig</label>
<input type="checkbox" id="dlig" value="dlig" class="otFeature" onchange="updateFeatures()"><label for="dlig" class="otFeatureLabel">dlig</label>
<input type="checkbox" id="salt" value="salt" class="otFeature" onchange="updateFeatures()"><label for="salt" class="otFeatureLabel">salt</label>
<input type="checkbox" id="ss01" value="ss01" class="otFeature" onchange="updateFeatures()"><label for="ss01" class="otFeatureLabel">ss01</label>
<input type="checkbox" id="ss02" value="ss02" class="otFeature" onchange="updateFeatures()"><label for="ss02" class="otFeatureLabel">ss02</label>
<input type="checkbox" id="ss03" value="ss03" class="otFeature" onchange="updateFeatures()"><label for="ss03" class="otFeatureLabel">ss03</label>
<input type="checkbox" id="ss04" value="ss04" class="otFeature" onchange="updateFeatures()"><label for="ss04" class="otFeatureLabel">ss04</label>
<label><input type="checkbox" value="show" onchange="updateFeatures();document.getElementById('featureLine').style.display=this.checked?'block':'none'">CSS</label>
<label><input type="checkbox" value="show" onchange="updateFeatures();document.getElementById('metricsLine').style.display=this.checked?'block':'none'">Metrics</label>
</p>
<p class="features" id="featureLine">font-feature-settings: "kern" on, "liga" on, "calt" on;</p>
</div>
<div id="waterfall" class="●">
<div id="metricsLine"></div>
<p><span class="label">08</span>&nbsp;<span class="sampletext" id="p08"></span></p>
<p><span class="label">09</span>&nbsp;<span class="sampletext" id="p09"></span></p>
<p><span class="label">10</span>&nbsp;<span class="sampletext" id="p10"></span></p>
<p><span class="label">11</span>&nbsp;<span class="sampletext" id="p11"></span></p>
<p><span class="label">12</span>&nbsp;<span class="sampletext" id="p12"></span></p>
<p><span class="label">13</span>&nbsp;<span class="sampletext" id="p13"></span></p>
<p><span class="label">14</span>&nbsp;<span class="sampletext" id="p14"></span></p>
<p><span class="label">15</span>&nbsp;<span class="sampletext" id="p15"></span></p>
<p><span class="label">16</span>&nbsp;<span class="sampletext" id="p16"></span></p>
<p><span class="sampletext" id="largeParagraph"></span></p>
<p><span class="sampletext" id="veryLargeParagraph"></span></p>
</div>
</div>
<!-- Disclaimer -->
<p id="helptext" onmouseleave="vanish(this);">
Ctrl-R: Reset Charset. Ctrl-L: Latin1. Ctrl-J: LTR/RTL. Ctrl-comma/period: step through fonts. Pull mouse across this note to make it disappear.
</p>
<script type="text/javascript">
const selector = document.getElementById("fontFamilySelector");
const selectorOptions = selector.options;
const selectorLength = selectorOptions.length;
document.addEventListener('keyup', keyAnalysis);
function keyAnalysis(event) {
if (event.ctrlKey) {
if (event.code == 'KeyR') {
setCharset();
} else if (event.code == 'KeyL') {
setLat1();
} else if (event.code == 'KeyJ') {
toggleLeftRight();
} else if (event.code == 'Period') {
selector.selectedIndex = (selector.selectedIndex + 1) % selectorLength;
changeFont();
} else if (event.code == 'Comma') {
var newIndex = selector.selectedIndex - 1;
if (newIndex<0) {
newIndex = selectorLength - 1;
}
selector.selectedIndex = newIndex;
changeFont();
}
}
}
function updateParagraph() {
// update paragraph text based on user input:
const txt = document.getElementById('textInput');
const paragraphs = document.getElementsByClassName('sampletext');
for (i = 0; i < paragraphs.length; i++) {
paragraph = paragraphs[i];
paragraph.textContent = txt.value;
}
// update other elements:
document.getElementById('metricsLine').textContent = txt.value;
}
function updateFeatures() {
// update features based on user input:
// first, get feature on/off line:
var cssCode = "";
var codeLine = "";
var checkboxes = document.getElementsByClassName("otFeature")
for (i = 0; i < checkboxes.length; i++) {
var checkbox = checkboxes[i];
codeLine += '"'+checkbox.id+'" ';
codeLine += checkbox.checked ? 'on, ' : 'off, ';
if (checkbox.name=="kern") {
cssCode += "font-kerning: "
cssCode += checkbox.checked ? 'normal; ' : 'none; ';
} else if (checkbox.name=="liga") {
codeLine += '"clig" '
codeLine += checkbox.checked ? 'on, ' : 'off, ';
cssCode += "font-variant-ligatures: "
cssCode += checkbox.checked ? 'common-ligatures contextual; ' : 'no-common-ligatures no-contextual; ';
} else if (checkbox.name=="dlig") {
cssCode += "font-variant-ligatures: "
cssCode += checkbox.checked ? 'discretionary-ligatures; ' : 'no-discretionary-ligatures; ';
} else if (checkbox.name=="hlig") {
cssCode += "font-variant-ligatures: "
cssCode += checkbox.checked ? 'historical-ligatures; ' : 'no-historical-ligatures; ';
}
}
codeLine = codeLine.slice(0, -2)
// then, apply line for every browser:
const prefixes = ["","-moz-","-webkit-","-ms-","-o-",];
const suffix = "font-feature-settings: "
for (i = 0; i < prefixes.length; i++) {
var prefix = prefixes[i];
cssCode += prefix
cssCode += suffix
cssCode += codeLine
cssCode += "; "
}
document.getElementById('waterfall').style.cssText = cssCode;
document.getElementById('featureLine').innerHTML = cssCode.replace(/;/g,";<br/>");
changeFont();
}
function changeFont() {
var selected_index = selector.selectedIndex;
var selected_option_text = selector.options[selected_index].text;
document.getElementById('waterfall').style.fontFamily = selected_option_text;
}
function setDefaultText(defaultText) {
document.getElementById('textInput').value = decodeEntities(defaultText);
updateParagraph();
}
function setLat1() {
const lat1 = "من نه آنم که زبونی کشم از چرخ فلک";
return setDefaultText(lat1);
}
function setCharset() {
const completeCharSet =
'من نه آنم که زبونی کشم از چرخ فلک'
setDefaultText(completeCharSet);
}
function decodeEntities(string){
var elem = document.createElement('div');
elem.innerHTML = string;
return elem.textContent;
}
function vanish(item) {
item.style.setProperty("display", "none");
}
function toggleLeftRight() {
const waterfall = document.getElementById("waterfall");
if (waterfall.dir != "rtl") {
waterfall.dir = "rtl";
waterfall.align = "right";
} else {
waterfall.dir = "";
waterfall.align = "";
}
}
function toggleInverse() {
const testText = document.getElementById("waterfall");
if (testText) {
const link = document.getElementById("invert");
if (testText.className == "●") {
testText.className = "○";
link.textContent = "🔳";
} else {
testText.className = "●";
link.textContent = "🔲";
}
}
}
</script>
</body>

Binary file not shown.

243
public/font/style.css Normal file
View File

@ -0,0 +1,243 @@
@import url(fontiran.css); /* لینک فایلی که وظیفه بارگذاری فونت ها را برعهده دارد */
body {
font-family: IRANYekanX !important;
direction: rtl;
background-color: #cdcdcd;
margin: 0;
}
h1, h2, h3, h4, h5, h6,input, textarea {
font-family: IRANYekanX !important;
}
h1 {
font-weight: bold;
}
.wrapper {
max-width: 900px;
margin: 0 auto;
}
.ltr {
direction: ltr;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-small {
font-size: 0.8em;
}
.text-xsmall {
font-size: 0.6em;
}
.text-large {
font-size: 1.2em;
}
.text-xlarge {
font-size: 1.4em;
}
.text-underline {
text-decoration:underline;
}
.text-thin {
font-weight: 100;
}
.text-UltraLight {
font-weight: 200;
}
.text-light {
font-weight: 300;
}
.text-regular {
font-weight: normal;
}
.text-medium {
font-weight: 500;
}
.text-demibold {
font-weight: 600;
}
.text-bold {
font-weight: bold;
}
.text-extrabold {
font-weight: 800;
}
.text-black {
font-weight: 900;
}
.text-extrablack {
font-weight: 950;
}
.text-heavy {
font-weight: 1000;
}
blockquote {
font-weight: 700;
padding: 10px;
border: 1px dashed #666666;
}
.mainbox {
width: 100%;
background-color: #EFEFEF;
display: table;
margin-bottom: 30px;
border-right: 8px solid #00adb5;
}
.mainboxnegativ {
width: 100%;
background-color: #303841;
display: table;
margin-bottom: 30px;
border-right: 8px solid #00adb5;
color: #F9F9F9;
}
.mainbox2 {
font-size: 1em;
width: 90%;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.mainboxitalic {
font-size: 1em;
font-style: italic;
width: 90%;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.mainbox3 {
width: 100%;
background-color: #DFDFDF;
display: table;
margin-bottom: 30px;
border-right: 8px solid #FF5EAA;
}
.mainbox2negativ {
font-size: 1em;
color: #F9F9F9;
background-color: #000000;
padding-right: 20px;
}
.farsiparagraph {
font-size: 1em;
width: 47%;
float:right;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.farsiparagraph_negativ {
font-size: 1em;
color: #F9F9F9;
background-color: #000000;
width: 47%;
float:right;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.englishparagraph {
font-size: 1em;
width: 47%;
float: left;
direction:ltr;
padding-left: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.englishparagraph_negativ {
font-size: 1em;
color: #F9F9F9;
background-color: #000000;
width: 47%;
float: left;
direction:ltr;
padding-left: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.rightbox {
width: 60%;
padding-right: 20px;
padding-left: 5px;
float: right;
margin-left: 10px;
margin-bottom: 0px;
min-width: 0px;
background-color: #F7F7F7;
}
.titelbox {
width: 60%;
padding-right: 25px;
padding-left: 0px;
float: right;
margin-left: 10px;
margin-bottom: 0px;
min-width: 0px;
background-color: #d5d5d5;
color: #4B4B4B;
}
.lefttbox {
padding-right: 20px;
padding-left: 4px;
float: right;
margin-bottom: 10px;
min-width: 0px;
}
.alphabet {
width: 35%;
float: left;
font-size: 20em;
text-align: center;
font-weight: 700;
color: #999999;
}
.alphabet2 {
width: 35%;
float: left;
direction: ltr;
font-size: 1.6em;
text-align: left;
font-weight: 600;
color: #333333;
margin-top: 100px;
}
.footer {
font-weight: 400;
font-size: 0.7em;
text-align: center;
direction: ltr;
margin-bottom: 0px;
padding-bottom: 0px;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,5 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
ssr: false,
} satisfies Config;