Compare commits
1 Commits
main
...
hotfix-api
| Author | SHA1 | Date | |
|---|---|---|---|
| 45a0222a0a |
|
|
@ -160,9 +160,9 @@ This document describes the exact implementation of the login page based on the
|
||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]"
|
className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]"
|
||||||
/>
|
/>
|
||||||
// <Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer">
|
<Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer">
|
||||||
// همیشه متصل بمانم
|
همیشه متصل بمانم
|
||||||
// </Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
|
|
|
||||||
125
app/app.css
125
app/app.css
|
|
@ -1,7 +1,13 @@
|
||||||
@import url(/font/fontiran.css);
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Persian/Farsi font support */
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap");
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
--font-sans:
|
||||||
|
"Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
|
||||||
/* Teal color scale */
|
/* Teal color scale */
|
||||||
--color-teal-50: #f0fdfa;
|
--color-teal-50: #f0fdfa;
|
||||||
--color-teal-100: #ccfbf1;
|
--color-teal-100: #ccfbf1;
|
||||||
|
|
@ -27,38 +33,17 @@
|
||||||
--color-slate-800: #1e293b;
|
--color-slate-800: #1e293b;
|
||||||
--color-slate-900: #0f172a;
|
--color-slate-900: #0f172a;
|
||||||
--color-slate-950: #020617;
|
--color-slate-950: #020617;
|
||||||
|
|
||||||
--color-pr-green: #3aea83;
|
|
||||||
--color-pr-blue: #69c8ea;
|
|
||||||
--color-pr-red: #f76276;
|
|
||||||
--color-pr-gray: #3f415a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: IRANYekanX;
|
|
||||||
direction: rtl;
|
|
||||||
background-color: #cdcdcd;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6,
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
font-family: IRANYekanX;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RTL Support */
|
/* RTL Support */
|
||||||
html[dir="rtl"] {
|
html[dir="rtl"] {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
|
|
@ -78,7 +63,6 @@ html[dir="rtl"] body {
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-dark-blue: var(--dark-blue);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
@ -98,13 +82,9 @@ html[dir="rtl"] body {
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--color-green: #3aea83;
|
|
||||||
--color-blue: #69c8ea;
|
|
||||||
--color-red: #f76276;
|
|
||||||
|
|
||||||
/* primary colors */
|
/* primary colors */
|
||||||
--color-pr-gray: #3f415a;
|
--color-pr-gray : #3F415A;
|
||||||
--color-pr-green: var(--color-green);
|
--color-pr-green :#3AEA83;
|
||||||
|
|
||||||
/* Light theme colors */
|
/* Light theme colors */
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
|
|
@ -126,7 +106,6 @@ html[dir="rtl"] body {
|
||||||
--border: #e5e5e5;
|
--border: #e5e5e5;
|
||||||
--input: #e5e5e5;
|
--input: #e5e5e5;
|
||||||
--ring: #22c55e;
|
--ring: #22c55e;
|
||||||
--dark-blue: #33364d;
|
|
||||||
|
|
||||||
/* Primary color scale */
|
/* Primary color scale */
|
||||||
--color-primary-50: #f0fdf4;
|
--color-primary-50: #f0fdf4;
|
||||||
|
|
@ -222,7 +201,7 @@ html[dir="rtl"] body {
|
||||||
--color-dark-950: #020617;
|
--color-dark-950: #020617;
|
||||||
|
|
||||||
/* Login specific colors */
|
/* Login specific colors */
|
||||||
--color-login-primary: var(--color-green);
|
--color-login-primary: #3aea83;
|
||||||
--color-login-dark-start: #464861;
|
--color-login-dark-start: #464861;
|
||||||
--color-login-dark-end: #111628;
|
--color-login-dark-end: #111628;
|
||||||
}
|
}
|
||||||
|
|
@ -258,11 +237,29 @@ html[dir="rtl"] body {
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 */
|
/* Persian/Farsi font class */
|
||||||
.font-persian {
|
.font-persian {
|
||||||
font-family: "IRANYekanX";
|
font-family: "Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom utility classes */
|
/* Custom utility classes */
|
||||||
|
|
@ -422,73 +419,25 @@ html[dir="rtl"] body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(
|
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.6), rgba(16, 185, 129, 0.9)); /* emerald */
|
||||||
to bottom,
|
|
||||||
rgba(16, 185, 129, 0.6),
|
|
||||||
rgba(16, 185, 129, 0.9)
|
|
||||||
); /* emerald */
|
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 0.5px solid transparent;
|
border: .5px solid transparent;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
.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 {
|
.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 {
|
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(30, 41, 59, 0.6); /* slate-800 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
font: inherit;
|
|
||||||
color: #5f6284;
|
|
||||||
background-color: transparent;
|
|
||||||
width: 1.15em;
|
|
||||||
height: 1.15em;
|
|
||||||
border: 1px solid #5f6284;
|
|
||||||
border-radius: 0.15em;
|
|
||||||
transform: translateY(-0.075em);
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]::before {
|
|
||||||
content: "";
|
|
||||||
width: 0.65em;
|
|
||||||
height: 0.65em;
|
|
||||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
|
||||||
transform: scale(0);
|
|
||||||
transform-origin: bottom left;
|
|
||||||
transition: 120ms transform ease-in-out;
|
|
||||||
box-shadow: inset 1em 1em var(--form-control-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]:checked::before {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]:checked {
|
|
||||||
background-color: #3aea83;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]:disabled {
|
|
||||||
--form-control-color: var(--form-control-disabled);
|
|
||||||
color: var(--form-control-disabled);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,23 +176,23 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Remember Me Checkbox */}
|
{/* Remember Me Checkbox */}
|
||||||
{/* <div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
id="remember"
|
id="remember"
|
||||||
label="همیشه متصل بمان"
|
label="همیشه متصل بمانم"
|
||||||
checked={formData.rememberMe}
|
checked={formData.rememberMe}
|
||||||
onChange={(checked) => updateField("rememberMe", checked)}
|
onChange={(checked) => updateField("rememberMe", checked)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
{/* Login Button */}
|
{/* Login Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || isConnectionError}
|
disabled={isLoading || isConnectionError}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full font-persian bg-[var(--color-login-primary)] hover:bg-[var(--color-login-primary)]/90 text-slate-800 text-base font-semibold"
|
className="w-full font-persian bg-[var(--color-login-primary)] hover:bg-[var(--color-login-primary)]/90 text-slate-800 font-bold"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -212,10 +212,7 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||||
{/* Right Side - Branding */}
|
{/* Right Side - Branding */}
|
||||||
<LoginSidebar>
|
<LoginSidebar>
|
||||||
<LoginBranding
|
<LoginBranding
|
||||||
brandName="پتروشیمی آپادانا"
|
brandName="پتروشیمی بندر امام"
|
||||||
// brandName="پتروشیمی نوری"
|
|
||||||
// brandName="پتروشیمی بندر امام"
|
|
||||||
engSub="Inception by Fara"
|
|
||||||
companyName="توسعهیافته توسط شرکت رهپویان دانش و فناوری فرا"
|
companyName="توسعهیافته توسط شرکت رهپویان دانش و فناوری فرا"
|
||||||
logo={<img src="/brand2.svg"/>}
|
logo={<img src="/brand2.svg"/>}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
interface LoginLayoutProps {
|
interface LoginLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -76,12 +75,12 @@ export function LoginHeader({
|
||||||
return (
|
return (
|
||||||
<div className={cn(" space-y-4 flex text-right flex-col", className)}>
|
<div className={cn(" space-y-4 flex text-right flex-col", className)}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-white text-base font-medium font-persian">{title}</h1>
|
<h1 className="text-white text-lg font-medium font-persian">{title}</h1>
|
||||||
<h2 className="text-white text-3xl sm:text-3xl font-bold font-persian leading-relaxed">
|
<h2 className="text-white text-2xl sm:text-3xl font-bold font-persian leading-relaxed">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</h2>
|
</h2>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-slate-300 text-sm text-[#ACACAC] font-persian leading-relaxed mx-auto">
|
<p className="text-slate-300 text-sm font-persian leading-relaxed mx-auto">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -95,7 +94,6 @@ interface LoginBrandingProps {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
logo?: React.ReactNode;
|
logo?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
engSub ?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginBranding({
|
export function LoginBranding({
|
||||||
|
|
@ -103,35 +101,22 @@ export function LoginBranding({
|
||||||
companyName,
|
companyName,
|
||||||
logo,
|
logo,
|
||||||
className,
|
className,
|
||||||
engSub
|
|
||||||
}: LoginBrandingProps) {
|
}: LoginBrandingProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end">
|
{/* Top Logo */}
|
||||||
<div className="text-slate-800 font-persian">
|
<div className="flex justify-end">
|
||||||
<div className="text-lg font-bold leading-tight">
|
<div className="text-slate-800 font-persian">
|
||||||
<img
|
<div className="text-lg font-bold leading-tight">
|
||||||
src="/brand.svg?v=1"
|
<img src="/brand.svg" />
|
||||||
alt="Brand Logo"
|
</div>
|
||||||
className="w-auto h-16" // اضافه کردن سایز مشخص
|
</div>
|
||||||
onError={(e) => {
|
</div>
|
||||||
e.target.style.display = 'none';
|
|
||||||
console.log('Image failed to load');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Bottom Section */}
|
{/* Bottom Section */}
|
||||||
<div className="flex flex-col gap-2 mb-4 items-end justify-end">
|
<div className="flex flex-col gap-2 mb-4 items-end justify-end">
|
||||||
{logo && <div className="flex items-center">{logo}</div>}
|
{logo && <div className="flex items-center">{logo}</div>}
|
||||||
<h3 className="text-[#3F415A] text-sm font-persian font-light leading-relaxed max-w-xs">{engSub}</h3>
|
<div className="text-slate-800 text-sm font-persian leading-relaxed max-w-xs">
|
||||||
<div className="text-[#3F415A] text-sm font-persian leading-relaxed font-light max-w-xs">
|
|
||||||
{companyName}
|
{companyName}
|
||||||
</div>
|
</div>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
//این فایل مخصوص
|
|
||||||
//شماتیک آپادانا
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { formatNumber } from "~/lib/utils";
|
import { formatNumber } from "~/lib/utils";
|
||||||
|
|
||||||
|
|
@ -11,10 +8,10 @@ export type CompanyInfo = {
|
||||||
costReduction: number;
|
costReduction: number;
|
||||||
revenue?: number;
|
revenue?: number;
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
costI: number;
|
costI : number,
|
||||||
capacityI: number;
|
capacityI : number,
|
||||||
revenueI: number;
|
revenueI : number,
|
||||||
cost: number | string;
|
cost : number | string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type D3ImageInfoProps = {
|
export type D3ImageInfoProps = {
|
||||||
|
|
@ -23,91 +20,69 @@ export type D3ImageInfoProps = {
|
||||||
height?: number;
|
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 (
|
return (
|
||||||
<div className={`info-box`} style={style}>
|
<div className={`info-box`} style={style}>
|
||||||
<div className="info-box-content">
|
<div className="info-box-content">
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<div className="info-label">درآمد:</div>
|
<div className="info-label">درآمد:</div>
|
||||||
<div className="info-value revenue text-[12px]">{formatNumber(company?.revenue || 0)}</div>
|
<div className="info-value revenue">{formatNumber(company?.revenue || 0)}</div>
|
||||||
<div className="info-unit">میلیون ریال</div>
|
<div className="info-unit">میلیون ریال</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<div className="info-label">هزینه:</div>
|
<div className="info-label">هزینه:</div>
|
||||||
{hideCapacity ? (
|
<div className="info-value cost">{formatNumber(company?.cost || 0)}</div>
|
||||||
<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 className="info-unit">میلیون ریال</div>
|
||||||
</div>
|
</div>
|
||||||
{!hideCapacity && (
|
<div className="info-row">
|
||||||
<div className="info-row">
|
<div className="info-label">ظرفیت:</div>
|
||||||
<div className="info-label">ظرفیت:</div>
|
<div className="info-value capacity">{formatNumber(company?.capacity || 0)}</div>
|
||||||
<div className="info-value capacity text-[12px]">{formatNumber(company?.capacity || 0)}</div>
|
<div className="info-unit">تن در سال</div>
|
||||||
<div className="info-unit">تن در سال</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function D3ImageInfo({ companies }: D3ImageInfoProps) {
|
export function D3ImageInfo({ companies }: D3ImageInfoProps) {
|
||||||
// واحدهای جدید - 4 واحد
|
// Ensure we have exactly 6 companies
|
||||||
const sample = [
|
const displayCompanies = companies;
|
||||||
{ id: "واحد 100", name: "واحد 100", imageUrl: "/abniro.png" },
|
|
||||||
{ id: "واحد 200", name: "واحد 200", imageUrl: "/besparan.png" },
|
|
||||||
{ id: "واحد 300", name: "واحد 300", imageUrl: "/khwarazmi.png" },
|
|
||||||
{ id: "واحد 400", name: "واحد 400", imageUrl: "/faravash1.png" }
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// Positions inside a 5x4 grid (col, row)
|
||||||
|
// Layout keeps same visual logic: left/middle/right on two bands with spacing grid around
|
||||||
const merged = sample.map(company => {
|
|
||||||
const found = companies.find(item => item.id === company.id);
|
|
||||||
return found
|
|
||||||
? found
|
|
||||||
: { ...company, cost: 0, capacity: 0, revenue: 0, costReduction: 0, costI: 0, capacityI: 0, revenueI: 0 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayCompanies = merged;
|
|
||||||
console.log(displayCompanies);
|
|
||||||
|
|
||||||
// موقعیتهای جدید برای چیدمان لوزی شکل (3 ردیف - 1-2-1)
|
|
||||||
// گرید 5x4 نگه داشته شده اما موقعیتها تغییر کرده
|
|
||||||
const gridPositions = [
|
const gridPositions = [
|
||||||
{ col: 2, row: 1, colI: 1, rowI: 1, name: "واحد 100" }, // ردیف اول - ستون اول
|
{ col: 2, row: 2 , colI : 1 , rowI : 2 , name : "بسپاران"}, // left - top band
|
||||||
{ col: 4, row: 1, colI: 5, rowI: 1, name: "واحد 200" }, // ردیف اول - ستون دوم
|
{ col: 3, row: 2 , colI : 3 , rowI : 1 , name : "خوارزمی"}, // middle top (image sits in row 2, info box goes to row 1)
|
||||||
{ col: 2, row: 3, colI: 1, rowI: 3, name: "واحد 300" }, // ردیف دوم - ستون اول
|
{ col: 4, row: 2 ,colI : 5 , rowI : 2 , name : "فراورش 1"}, // right - top band
|
||||||
{ col: 4, row: 3, colI: 5, rowI: 3, name: "واحد 400" }, // ردیف دوم - ستون دوم
|
{ 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 (
|
return (
|
||||||
<div className="w-full h-[500px] rounded-xl">
|
<div className="w-full h-[500px] rounded-xl p-4">
|
||||||
<div dir="ltr" className="company-grid-container">
|
<div dir="ltr" className="company-grid-container">
|
||||||
{displayCompanies.map((company, index) => {
|
{displayCompanies.map((company, index) => {
|
||||||
const gp = gridPositions.find(v => v.name === company.name);
|
const gp = gridPositions.find(v => v.name === company.name) ;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={company.id}>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`company-item`}
|
key={company.id}
|
||||||
style={{ gridColumn: gp?.col, gridRow: gp?.row }}
|
className={`company-item`}
|
||||||
>
|
style={{ gridColumn: gp.col, gridRow: gp.row }}
|
||||||
<div className="company-image-container">
|
>
|
||||||
<img
|
<div className="company-image-containe">
|
||||||
src={company.imageUrl}
|
<img
|
||||||
alt={company.name}
|
src={company.imageUrl}
|
||||||
className="company-image"
|
alt={company.name}
|
||||||
/>
|
className="company-image"
|
||||||
</div>
|
/>
|
||||||
{company.name}
|
|
||||||
</div>
|
</div>
|
||||||
<InfoBox company={company} style={{ gridColumn: gp?.colI, gridRow: gp?.rowI }} />
|
|
||||||
</React.Fragment>
|
{company.name}
|
||||||
);
|
</div>
|
||||||
|
<InfoBox company={company} key={index +10} style={{ gridColumn: gp?.colI , gridRow: gp?.rowI }} />
|
||||||
|
</>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -139,70 +114,65 @@ export function D3ImageInfo({ companies }: D3ImageInfoProps) {
|
||||||
|
|
||||||
.company-image {
|
.company-image {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
height: 100px;
|
height : 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
border: 1px solid #3F415A;
|
border: 1px solid #3F415A;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
height: max-content;
|
height: max-content;
|
||||||
align-self: center;
|
align-self : center;
|
||||||
justify-self: center;
|
justify-self : center;
|
||||||
padding: .2rem 1.2rem;
|
padding : .2rem 0 ;
|
||||||
min-width: 8rem;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.info-box-content {
|
.info-box-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
position: relative;
|
position : relative;
|
||||||
margin: .1rem 0;
|
margin: 0rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5rem;
|
gap : 1rem;
|
||||||
justify-content: space-between;
|
justify-content : space-between;
|
||||||
|
padding: 0rem .8rem;
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:has(.info-value.revenue) {
|
&:has(.info-value.revenue) {border-bottom: 1px solid #3AEA83;}
|
||||||
border-bottom: 1px solid #3AEA83;
|
&:has(.info-value.cost) {border-bottom: 1px solid #F76276;}
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:has(.info-value.cost) {
|
|
||||||
border-bottom: 1px solid #F76276;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin: auto 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
color: #34D399;
|
color: #34D399;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
text-align: right;
|
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.cost { color: #fff; }
|
||||||
.info-value.cost2 { color: #fff; }
|
|
||||||
.info-value.capacity { color: #fff; }
|
.info-value.capacity { color: #fff; }
|
||||||
|
|
||||||
.info-unit {
|
.info-unit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 12px;
|
||||||
bottom: 2px;
|
bottom: 0;
|
||||||
color: #ACACAC;
|
color: #9CA3AF;
|
||||||
font-size: 6px;
|
font-size: 8px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
//این فایل مخصوص
|
|
||||||
//شماتیک بندر امام
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
//این فایل مخصوص
|
|
||||||
//شماتیک نوری
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { formatNumber } from "~/lib/utils";
|
import { formatNumber } from "~/lib/utils";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "~/components/ui/tooltip"
|
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -26,7 +21,7 @@ export function DashboardCustomBarChart({
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h3 className="text-sm font-bold text-white font-persian mb-4 text-right border-b-2 border-gray-500/20 pb-3">
|
<h3 className="text-lg font-bold text-white font-persian mb-4 text-center border-b-2 border-gray-500/20 pb-3">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -45,7 +40,7 @@ export function DashboardCustomBarChart({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h3 className="text-sm font-bold text-white font-persian mb-6 py-2 px-4 text-right border-b-2 border-gray-500/20">
|
<h3 className="text-lg font-bold text-white font-persian mb-4 text-center border-b-2 border-gray-500/20">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
|
|
@ -56,34 +51,19 @@ export function DashboardCustomBarChart({
|
||||||
return (
|
return (
|
||||||
<div key={index} className="relative">
|
<div key={index} className="relative">
|
||||||
{/* Bar container */}
|
{/* Bar container */}
|
||||||
<div className="flex-row-reverse items-center gap-2 flex min-h-6 h-10 rounded-lg overflow-hidden">
|
<div className="relative min-h-6 h-10 rounded-lg overflow-hidden">
|
||||||
{/* Animated bar */}
|
{/* Animated bar */}
|
||||||
<div
|
<div
|
||||||
className={`h-auto gap-2 overflow-hidden ${item.color} rounded-lg transition-all duration-1000 ease-out flex items-center justify-end px-2`}
|
className={`absolute left-0 h-auto gap-2 top-0 ${item.color} rounded-lg transition-all duration-1000 ease-out flex items-center justify-between px-2`}
|
||||||
style={{ width: `${widthPercentage}%` }}
|
style={{ width: `${widthPercentage}%` }}
|
||||||
>
|
>
|
||||||
{ 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">
|
<span className="text-white font-bold text-base">
|
||||||
{formatNumber(item.value)}
|
{formatNumber(item.value)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[#3F415A] font-persian font-medium text-sm w-max">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,403 +1,42 @@
|
||||||
import { saveAs } from "file-saver";
|
import { useEffect, useState } from "react";
|
||||||
import jalaali from "jalaali-js";
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
ChevronLeft,
|
|
||||||
FileChartColumnIncreasing,
|
|
||||||
Menu,
|
|
||||||
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 { useAuth } from "~/contexts/auth-context";
|
||||||
import apiService from "~/lib/api";
|
import { Link } from "react-router";
|
||||||
import { cn, EventBus, handleDataValue } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
PanelLeft,
|
||||||
|
Search,
|
||||||
|
Bell,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Menu,
|
||||||
|
ChevronDown,
|
||||||
|
Globe,
|
||||||
|
HelpCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: 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({
|
export function Header({
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
className,
|
className,
|
||||||
title = "صفحه اول",
|
title = "صفحه اول",
|
||||||
titleIcon,
|
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { jy } = jalaali.toJalaali(new Date());
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
|
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
|
||||||
const calendarRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState<boolean>(false);
|
|
||||||
const [isNotificationOpen, setIsNotificationOpen] = useState<boolean>(false);
|
|
||||||
const [openCalendar, setOpenCalendar] = useState<boolean>(false);
|
|
||||||
const [excelLoading, setExcelLoading] = useState<boolean>(false);
|
|
||||||
const location = useLocation();
|
|
||||||
const projectManagerRoute = "/dashboard/project-management";
|
|
||||||
const [currentYear, setCurrentYear] = useState<SelectedDate>({
|
|
||||||
since: jy,
|
|
||||||
until: jy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<CurrentDay>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedDate = localStorage.getItem("dateSelected");
|
|
||||||
if (storedDate) {
|
|
||||||
const parsedDate = JSON.parse(storedDate);
|
|
||||||
setSelectedDate(parsedDate);
|
|
||||||
|
|
||||||
const sinceYear = parsedDate.start
|
|
||||||
? parseInt(parsedDate.start.split("/")[0], 10)
|
|
||||||
: jy;
|
|
||||||
const untilYear = parsedDate.end
|
|
||||||
? parseInt(parsedDate.end.split("/")[0], 10)
|
|
||||||
: jy;
|
|
||||||
|
|
||||||
setCurrentYear({ since: sinceYear, until: untilYear });
|
|
||||||
} else {
|
|
||||||
const defaultDate = {
|
|
||||||
sinceMonth: "بهار",
|
|
||||||
fromMonth: "زمستان",
|
|
||||||
start: `${jy}/01/01`,
|
|
||||||
end: `${jy}/12/30`,
|
|
||||||
};
|
|
||||||
setSelectedDate(defaultDate);
|
|
||||||
localStorage.setItem("dateSelected", JSON.stringify(defaultDate));
|
|
||||||
setCurrentYear({ since: jy, until: jy });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const redirectHandler = async () => {
|
|
||||||
try {
|
|
||||||
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"backdrop-blur-sm border-b border-gray-400/30 h-16 flex items-center justify-between px-4 lg:px-6 shadow-sm relative z-30",
|
"backdrop-blur-sm border-b border-gray-400/30 h-16 flex items-center justify-between px-4 lg:px-6 shadow-sm relative z-30",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Left Section */}
|
{/* Left Section */}
|
||||||
|
|
@ -416,129 +55,33 @@ export function Header({
|
||||||
|
|
||||||
{/* Page Title */}
|
{/* Page Title */}
|
||||||
<h1 className="text-xl flex items-center justify-center gap-4 font-bold text-white font-persian">
|
<h1 className="text-xl flex items-center justify-center gap-4 font-bold text-white font-persian">
|
||||||
{/* Right-side icon for current page */}
|
<PanelLeft /> {title}
|
||||||
{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>
|
</h1>
|
||||||
|
|
||||||
<div ref={calendarRef} className="flex flex-col gap-3 relative">
|
|
||||||
<div
|
|
||||||
onClick={toggleCalendar}
|
|
||||||
className="flex flex-row w-full gap-2 items-center border border-pr-gray p-1.5 rounded-md px-2.5 min-w-64 cursor-pointer hover:bg-pr-gray/50 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Calendar size={20} />
|
|
||||||
{selectedDate ? (
|
|
||||||
<div className="flex flex-row justify-between w-full min-w-36 font-bold gap-1">
|
|
||||||
<div className="flex flex-row gap-1.5 w-max">
|
|
||||||
<span className="text-md">از</span>
|
|
||||||
<span className="text-md">{selectedDate?.sinceMonth}</span>
|
|
||||||
<span className="text-md">
|
|
||||||
{handleDataValue(currentYear.since)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-1.5 w-max">
|
|
||||||
<span className="text-md">تا</span>
|
|
||||||
<span className="text-md">{selectedDate?.fromMonth}</span>
|
|
||||||
<span className="text-md">
|
|
||||||
{handleDataValue(currentYear.until)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"تاریخ مورد نظر خود را انتخاب نمایید"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{openCalendar && (
|
|
||||||
<div className="flex flex-row gap-2.5 absolute top-14 right-[-40px] p-2.5 !pt-3.5 w-80 rounded-3xl overflow-hidden bg-pr-gray border-2 border-[#5F6284]">
|
|
||||||
<CustomCalendar
|
|
||||||
title="از"
|
|
||||||
nextYearHandler={prevFromYearHandler}
|
|
||||||
prevYearHandler={nextFromYearHandler}
|
|
||||||
currentYear={handleDataValue(currentYear?.since)}
|
|
||||||
monthList={monthList}
|
|
||||||
selectedDate={selectedDate?.sinceMonth}
|
|
||||||
selectDateHandler={selectFromDateHandler}
|
|
||||||
/>
|
|
||||||
<span className="w-0.5 h-[12.5rem] border border-[#5F6284] block "></span>
|
|
||||||
<CustomCalendar
|
|
||||||
title="تا"
|
|
||||||
nextYearHandler={prevUntilYearHandler}
|
|
||||||
prevYearHandler={nextUntilYearHandler}
|
|
||||||
currentYear={handleDataValue(currentYear?.until)}
|
|
||||||
monthList={monthList}
|
|
||||||
selectedDate={selectedDate?.fromMonth}
|
|
||||||
selectDateHandler={selectUntilDateHandler}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section */}
|
{/* Right Section */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
{location.pathname === projectManagerRoute ? (
|
variant="ghost"
|
||||||
<div className="flex justify-end w-full mb-0 pl-2">
|
size="sm"
|
||||||
<span
|
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
||||||
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" : ""}`}
|
className="flex items-center gap-2 text-gray-300"
|
||||||
onClick={handleDownloadFile}
|
>
|
||||||
>
|
<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">
|
||||||
<FileChartColumnIncreasing className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
دانلود فایل اکسل
|
</div>
|
||||||
</span>
|
<div className="hidden sm:block text-right">
|
||||||
|
<div className="text-sm font-medium font-persian">
|
||||||
|
{user?.name} {user?.family}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="text-xs text-gray-400 font-persian">
|
||||||
""
|
{user?.username}
|
||||||
)}
|
|
||||||
|
|
||||||
{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
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
|
||||||
className="flex items-center gap-2 text-gray-300"
|
|
||||||
>
|
|
||||||
<div className="hidden sm:block text-right">
|
|
||||||
<div className="text-sm font-medium font-persian">
|
|
||||||
{user?.name} {user?.family}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 font-persian">
|
|
||||||
{user?.username}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<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">
|
</div>
|
||||||
<User className="h-4 w-4" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</div>
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile Dropdown */}
|
{/* Profile Dropdown */}
|
||||||
{isProfileMenuOpen && (
|
{isProfileMenuOpen && (
|
||||||
|
|
@ -551,7 +94,7 @@ export function Header({
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="py-1">
|
<div className="py-1">
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/profile"
|
to="/dashboard/profile"
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian"
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300 font-persian"
|
||||||
|
|
@ -560,7 +103,7 @@ export function Header({
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
پروفایل کاربری
|
پروفایل کاربری
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/settings"
|
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"
|
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)}
|
onClick={() => setIsProfileMenuOpen(false)}
|
||||||
|
|
@ -568,7 +111,7 @@ export function Header({
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
تنظیمات
|
تنظیمات
|
||||||
</Link>
|
</Link>
|
||||||
</div> */}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,15 @@ export function InteractiveBarChart({
|
||||||
data: CompanyChartDatum[];
|
data: CompanyChartDatum[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="py-0 bg-transparent mt-8 border-none h-full">
|
<Card className="py-0 bg-transparent mt-20 border-none h-full">
|
||||||
<CardContent className="p-2 bg-transparent">
|
<CardContent className="px-2 sm:p-6 bg-transparent">
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-96">
|
<ChartContainer config={chartConfig} className="aspect-auto h-96 w-full">
|
||||||
<BarChart
|
<BarChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ left: 12, right: 12 }}
|
margin={{ left: 12, right: 12 }}
|
||||||
barGap={25}
|
barGap={15}
|
||||||
barSize={9}
|
barSize={8}
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} stroke="#475569" />
|
<CartesianGrid vertical={false} stroke="#475569" />
|
||||||
<XAxis
|
<XAxis
|
||||||
|
|
@ -59,21 +59,21 @@ export function InteractiveBarChart({
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
style={{ fill: "#ffffff", fontSize: 16 }}
|
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={25}
|
tickMargin={8}
|
||||||
style={{ fill: "#ACACAC", fontSize: 11 }}
|
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
||||||
tickFormatter={(value) => `${formatNumber(Math.round(value))}%`}
|
tickFormatter={(value) => `${formatNumber(Math.round(value))}%`}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="capacity" fill={chartConfig.capacity.color} radius={[8, 8, 0, 0]}>
|
<Bar dataKey="capacity" fill={chartConfig.capacity.color} radius={[8, 8, 0, 0]}>
|
||||||
<LabelList
|
<LabelList
|
||||||
dataKey="capacity"
|
dataKey="capacity"
|
||||||
position="top"
|
position="top"
|
||||||
offset={15}
|
style={{ fill: "#ffffff", fontSize: "12px", fontWeight: "bold" }}
|
||||||
style={{ fill: "#ffffff", fontSize: "16px", fontWeight: "bold" }}
|
|
||||||
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
||||||
/>
|
/>
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|
@ -81,7 +81,7 @@ export function InteractiveBarChart({
|
||||||
<LabelList
|
<LabelList
|
||||||
dataKey="revenue"
|
dataKey="revenue"
|
||||||
position="top"
|
position="top"
|
||||||
style={{ fill: "#ffffff", fontSize: "16px", fontWeight: "bold" }}
|
style={{ fill: "#ffffff", fontSize: "12px", fontWeight: "bold" }}
|
||||||
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
||||||
/>
|
/>
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|
@ -89,7 +89,7 @@ export function InteractiveBarChart({
|
||||||
<LabelList
|
<LabelList
|
||||||
dataKey="cost"
|
dataKey="cost"
|
||||||
position="top"
|
position="top"
|
||||||
style={{ fill: "#ffffff", fontSize: "16px", fontWeight: "bold" }}
|
style={{ fill: "#ffffff", fontSize: "12px", fontWeight: "bold" }}
|
||||||
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
||||||
/>
|
/>
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|
@ -97,27 +97,27 @@ export function InteractiveBarChart({
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
{/* Legend below chart */}
|
{/* Legend below chart */}
|
||||||
<div className="flex justify-center gap-8 mt-10">
|
<div className="flex justify-center gap-8 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-2 rounded"
|
className="w-6 h-2 rounded"
|
||||||
style={{ backgroundColor: chartConfig.capacity.color }}
|
style={{ backgroundColor: chartConfig.capacity.color }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-white">{chartConfig.capacity.label}</span>
|
<span className="text-sm text-white">{chartConfig.capacity.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-2 rounded"
|
className="w-6 h-2 rounded"
|
||||||
style={{ backgroundColor: chartConfig.cost.color }}
|
style={{ backgroundColor: chartConfig.cost.color }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-white">{chartConfig.cost.label}</span>
|
<span className="text-sm text-white">{chartConfig.cost.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-2 rounded"
|
className="w-6 h-2 rounded"
|
||||||
style={{ backgroundColor: chartConfig.revenue.color }}
|
style={{ backgroundColor: chartConfig.revenue.color }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-white">{chartConfig.revenue.label}</span>
|
<span className="text-sm text-white">{chartConfig.revenue.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Header } from "./header";
|
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
|
import { Header } from "./header";
|
||||||
import { StrategicAlignmentPopup } from "./strategic-alignment-popup";
|
import { StrategicAlignmentPopup } from "./strategic-alignment-popup";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
|
|
@ -17,14 +17,7 @@ export function DashboardLayout({
|
||||||
}: DashboardLayoutProps) {
|
}: DashboardLayoutProps) {
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] =
|
const [isStrategicAlignmentPopupOpen, setIsStrategicAlignmentPopupOpen] = useState(false);
|
||||||
useState(false);
|
|
||||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>(
|
|
||||||
title ?? "صفحه اول"
|
|
||||||
);
|
|
||||||
const [currentTitleIcon, setCurrentTitleIcon] = useState<
|
|
||||||
React.ComponentType<{ className?: string }> | null | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const toggleSidebarCollapse = () => {
|
const toggleSidebarCollapse = () => {
|
||||||
setIsSidebarCollapsed(!isSidebarCollapsed);
|
setIsSidebarCollapsed(!isSidebarCollapsed);
|
||||||
|
|
@ -37,7 +30,7 @@ export function DashboardLayout({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen flex overflow-hidden bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)] relative overflow-x-hidden"
|
className="h-screen flex overflow-hidden bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)] relative overflow-x-hidden"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
{/* Gradient overlay */}
|
{/* Gradient overlay */}
|
||||||
<div className="absolute inset-0 pointer-events-none" />
|
<div className="absolute inset-0 pointer-events-none" />
|
||||||
|
|
@ -57,20 +50,14 @@ export function DashboardLayout({
|
||||||
"fixed inset-y-0 right-0 z-50 flex flex-col lg:static lg:inset-auto lg:translate-x-0 transition-transform duration-300 ease-in-out",
|
"fixed inset-y-0 right-0 z-50 flex flex-col lg:static lg:inset-auto lg:translate-x-0 transition-transform duration-300 ease-in-out",
|
||||||
isMobileSidebarOpen
|
isMobileSidebarOpen
|
||||||
? "translate-x-0"
|
? "translate-x-0"
|
||||||
: "translate-x-full lg:translate-x-0"
|
: "translate-x-full lg:translate-x-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isCollapsed={isSidebarCollapsed}
|
isCollapsed={isSidebarCollapsed}
|
||||||
onToggleCollapse={toggleSidebarCollapse}
|
onToggleCollapse={toggleSidebarCollapse}
|
||||||
className="h-full flex-shrink-0 relative z-10"
|
className="h-full flex-shrink-0 relative z-10"
|
||||||
onStrategicAlignmentClick={() =>
|
onStrategicAlignmentClick={() => setIsStrategicAlignmentPopupOpen(true)}
|
||||||
setIsStrategicAlignmentPopupOpen(true)
|
|
||||||
}
|
|
||||||
onTitleChange={(info) => {
|
|
||||||
setCurrentTitle(info.title);
|
|
||||||
setCurrentTitleIcon(info.icon ?? null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,26 +67,22 @@ export function DashboardLayout({
|
||||||
<Header
|
<Header
|
||||||
onToggleSidebar={toggleMobileSidebar}
|
onToggleSidebar={toggleMobileSidebar}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
title={currentTitle}
|
title={title}
|
||||||
titleIcon={currentTitleIcon}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 overflow-x-hidden overflow-y-auto focus:outline-none transition-all duration-300 min-w-0",
|
"flex-1 overflow-x-hidden overflow-y-auto focus:outline-none transition-all duration-300 min-w-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative h-full min-w-0 w-full z-10 overflow-x-hidden p-5">
|
<div className="relative h-full min-w-0 w-full z-10 overflow-x-hidden">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StrategicAlignmentPopup
|
<StrategicAlignmentPopup open={isStrategicAlignmentPopupOpen} onOpenChange={setIsStrategicAlignmentPopupOpen} />
|
||||||
open={isStrategicAlignmentPopupOpen}
|
|
||||||
onOpenChange={setIsStrategicAlignmentPopupOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import moment from "moment-jalaali";
|
import moment from "moment-jalaali";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { BaseCard } from "~/components/ui/base-card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
|
@ -34,10 +33,8 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { EventBus, formatCurrency, formatNumber } from "~/lib/utils";
|
import { formatCurrency } from "~/lib/utils";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
import { DashboardLayout } from "../layout";
|
import { DashboardLayout } from "../layout";
|
||||||
|
|
||||||
moment.loadPersian({ usePersianDigits: true });
|
moment.loadPersian({ usePersianDigits: true });
|
||||||
|
|
@ -155,7 +152,7 @@ export function DigitalInnovationPage() {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize] = useState(20);
|
const [pageSize] = useState(20);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [date, setDate] = useStoredDate();
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [actualTotalCount, setActualTotalCount] = useState(0);
|
const [actualTotalCount, setActualTotalCount] = useState(0);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [rating, setRating] = useState<ListItem[]>([]);
|
const [rating, setRating] = useState<ListItem[]>([]);
|
||||||
|
|
@ -183,8 +180,6 @@ export function DigitalInnovationPage() {
|
||||||
// const [avarage, setAvarage] = useState<number>(0);
|
// const [avarage, setAvarage] = useState<number>(0);
|
||||||
const observerRef = useRef<HTMLDivElement>(null);
|
const observerRef = useRef<HTMLDivElement>(null);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Selection handlers
|
// Selection handlers
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
|
|
@ -206,7 +201,12 @@ export function DigitalInnovationPage() {
|
||||||
setDetailsDialogOpen(true);
|
setDetailsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...existing code...
|
const formatNumber = (value: string | number) => {
|
||||||
|
if (!value) return "0";
|
||||||
|
const numericValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (isNaN(numericValue)) return "0";
|
||||||
|
return new Intl.NumberFormat("fa-IR").format(numericValue);
|
||||||
|
};
|
||||||
|
|
||||||
const statsCards: StatsCard[] = [
|
const statsCards: StatsCard[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -215,7 +215,7 @@ export function DigitalInnovationPage() {
|
||||||
value: formatNumber(stats.reduceCosts.toFixed?.(1) ?? stats.reduceCosts),
|
value: formatNumber(stats.reduceCosts.toFixed?.(1) ?? stats.reduceCosts),
|
||||||
description: "میلیون ریال کاهش یافته",
|
description: "میلیون ریال کاهش یافته",
|
||||||
icon: <TrendingDown />,
|
icon: <TrendingDown />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bottleneck-removal",
|
id: "bottleneck-removal",
|
||||||
|
|
@ -223,7 +223,7 @@ export function DigitalInnovationPage() {
|
||||||
value: formatNumber(stats.increasedRevenue),
|
value: formatNumber(stats.increasedRevenue),
|
||||||
description: "میلیون ریال افزایش یافته",
|
description: "میلیون ریال افزایش یافته",
|
||||||
icon: <TrendingUp />,
|
icon: <TrendingUp />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -234,7 +234,7 @@ export function DigitalInnovationPage() {
|
||||||
),
|
),
|
||||||
description: "هزار تن صرفه جوریی شده",
|
description: "هزار تن صرفه جوریی شده",
|
||||||
icon: <Database />,
|
icon: <Database />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frequent-failures-reduction",
|
id: "frequent-failures-reduction",
|
||||||
|
|
@ -245,7 +245,7 @@ export function DigitalInnovationPage() {
|
||||||
),
|
),
|
||||||
description: "مگاوات کاهش یافته",
|
description: "مگاوات کاهش یافته",
|
||||||
icon: <Zap />,
|
icon: <Zap />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -284,11 +284,7 @@ export function DigitalInnovationPage() {
|
||||||
"reduce_costs_percent",
|
"reduce_costs_percent",
|
||||||
],
|
],
|
||||||
Sorts: [[sortConfig.field, sortConfig.direction]],
|
Sorts: [[sortConfig.field, sortConfig.direction]],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری دیجیتال"]],
|
||||||
["type_of_innovation", "=", "نوآوری دیجیتال", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -301,16 +297,16 @@ export function DigitalInnovationPage() {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects(parsedData);
|
setProjects(parsedData);
|
||||||
// calculateAverage(parsedData);
|
// calculateAverage(parsedData);
|
||||||
// setTotalCount(parsedData.length);
|
setTotalCount(parsedData.length);
|
||||||
} else {
|
} else {
|
||||||
setProjects((prev) => [...prev, ...parsedData]);
|
setProjects((prev) => [...prev, ...parsedData]);
|
||||||
// setTotalCount((prev) => prev + parsedData.length);
|
setTotalCount((prev) => prev + parsedData.length);
|
||||||
}
|
}
|
||||||
setHasMore(parsedData.length === pageSize);
|
setHasMore(parsedData.length === pageSize);
|
||||||
} else {
|
} else {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -318,14 +314,14 @@ export function DigitalInnovationPage() {
|
||||||
console.error("Error parsing project data:", parseError);
|
console.error("Error parsing project data:", parseError);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +329,7 @@ export function DigitalInnovationPage() {
|
||||||
toast.error(response.message || "خطا در دریافت اطلاعات پروژهها");
|
toast.error(response.message || "خطا در دریافت اطلاعات پروژهها");
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +338,7 @@ export function DigitalInnovationPage() {
|
||||||
toast.error("خطا در دریافت اطلاعات پروژهها");
|
toast.error("خطا در دریافت اطلاعات پروژهها");
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -354,75 +350,45 @@ export function DigitalInnovationPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
|
if (!loadingMore && hasMore && !loading) {
|
||||||
setCurrentPage((prev) => prev + 1);
|
setCurrentPage((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [hasMore, loading, loadingMore]);
|
}, [loadingMore, hasMore, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date?.start && date?.end) {
|
fetchTable(true);
|
||||||
fetchTable(true);
|
fetchTotalCount();
|
||||||
fetchTotalCount();
|
fetchStats();
|
||||||
fetchStats();
|
}, [sortConfig]);
|
||||||
}
|
|
||||||
}, [sortConfig, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (date: CalendarDate) => {
|
if (currentPage > 1) {
|
||||||
if (date) setDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentPage > 1 && date?.start && date?.end) {
|
|
||||||
fetchTable(false);
|
fetchTable(false);
|
||||||
}
|
}
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
|
|
||||||
// Infinite scroll observer with debouncing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = document.querySelector(".overflow-auto");
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
|
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||||
return;
|
|
||||||
|
|
||||||
// Clear previous timeout
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
if (scrollTimeoutRef.current) {
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
|
if (scrollPercentage >= 0.9) {
|
||||||
|
loadMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (scrollContainer) {
|
||||||
scrollContainer.addEventListener("scroll", handleScroll, {
|
scrollContainer.addEventListener("scroll", handleScroll);
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.removeEventListener("scroll", handleScroll);
|
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||||
}
|
}
|
||||||
if (scrollTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [loadMore, hasMore, loadingMore]);
|
}, [loadMore, hasMore, loadingMore]);
|
||||||
|
|
||||||
|
|
@ -433,23 +399,19 @@ export function DigitalInnovationPage() {
|
||||||
direction:
|
direction:
|
||||||
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
|
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
|
||||||
}));
|
}));
|
||||||
fetchTotalCount(date?.start, date?.end);
|
fetchTotalCount();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTotalCount = async (startDate?: string, endDate?: string) => {
|
const fetchTotalCount = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiService.select({
|
const response = await apiService.select({
|
||||||
ProcessName: "project",
|
ProcessName: "project",
|
||||||
OutputFields: ["count(project_no)"],
|
OutputFields: ["count(project_no)"],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری دیجیتال"]],
|
||||||
["type_of_innovation", "=", "نوآوری دیجیتال", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -476,12 +438,11 @@ export function DigitalInnovationPage() {
|
||||||
try {
|
try {
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
const raw = await apiService.call<any>({
|
const raw = await apiService.call<any>({
|
||||||
innovation_digital_function: {
|
innovation_digital_function: {},
|
||||||
start_date: date?.start || null,
|
|
||||||
end_date: date?.end || null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// let payload: DigitalInnovationMetrics = raw?.data;
|
// let payload: DigitalInnovationMetrics = raw?.data;
|
||||||
// console.log("*-*-*-*" +payload);
|
// console.log("*-*-*-*" +payload);
|
||||||
// if (typeof payload === "string") {
|
// if (typeof payload === "string") {
|
||||||
|
|
@ -510,6 +471,8 @@ export function DigitalInnovationPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const parseNum = (v: unknown): number => {
|
const parseNum = (v: unknown): number => {
|
||||||
if (v == null) return 0;
|
if (v == null) return 0;
|
||||||
if (typeof v === "number") return v;
|
if (typeof v === "number") return v;
|
||||||
|
|
@ -557,33 +520,33 @@ export function DigitalInnovationPage() {
|
||||||
// fetchStats();
|
// fetchStats();
|
||||||
// };
|
// };
|
||||||
|
|
||||||
// const renderProgress = useMemo(() => {
|
const renderProgress = useMemo(() => {
|
||||||
// const total = 10;
|
const total = 10;
|
||||||
// for (let i = 0; i < rating.length; i++) {
|
for (let i = 0; i < rating.length; i++) {
|
||||||
// const currentElm = rating[i];
|
const currentElm = rating[i];
|
||||||
// currentElm.house = [];
|
currentElm.house = [];
|
||||||
// const greenBoxes = Math.floor((total * currentElm.development) / 100);
|
const greenBoxes = Math.floor((total * currentElm.development) / 100);
|
||||||
// const partialPercent =
|
const partialPercent =
|
||||||
// (total * currentElm.development) / 100 - greenBoxes;
|
(total * currentElm.development) / 100 - greenBoxes;
|
||||||
// for (let j = 0; j < greenBoxes; j++) {
|
for (let j = 0; j < greenBoxes; j++) {
|
||||||
// currentElm.house.push({
|
currentElm.house.push({
|
||||||
// index: j,
|
index: j,
|
||||||
// color: "!bg-emerald-400",
|
color: "!bg-emerald-400",
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// if (partialPercent != 0 && greenBoxes != 10)
|
if (partialPercent != 0 && greenBoxes != 10)
|
||||||
// currentElm.house.push({
|
currentElm.house.push({
|
||||||
// index: greenBoxes + 1,
|
index: greenBoxes + 1,
|
||||||
// style: `linear-gradient(
|
style: `linear-gradient(
|
||||||
// to right,
|
to right,
|
||||||
// oklch(76.5% 0.177 163.223) 0%,
|
oklch(76.5% 0.177 163.223) 0%,
|
||||||
// oklch(76.5% 0.177 163.223) ${partialPercent * 100}%,
|
oklch(76.5% 0.177 163.223) ${partialPercent * 100}%,
|
||||||
// oklch(55.1% 0.027 264.364) ${partialPercent * 100}%,
|
oklch(55.1% 0.027 264.364) ${partialPercent * 100}%,
|
||||||
// oklch(55.1% 0.027 264.364) 100%
|
oklch(55.1% 0.027 264.364) 100%
|
||||||
// )`,
|
)`,
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// }, [rating]);
|
}, [rating]);
|
||||||
|
|
||||||
const statusColor = (status: projectStatus): any => {
|
const statusColor = (status: projectStatus): any => {
|
||||||
let el = null;
|
let el = null;
|
||||||
|
|
@ -627,14 +590,14 @@ export function DigitalInnovationPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleProjectDetails(item)}
|
onClick={() => handleProjectDetails(item)}
|
||||||
className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
|
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer"
|
||||||
>
|
>
|
||||||
جزئیات بیشتر
|
جزئیات بیشتر
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case "amount_currency_reduction":
|
case "amount_currency_reduction":
|
||||||
return (
|
return (
|
||||||
<span className="font-medium text-pr-green">
|
<span className="font-medium text-emerald-400">
|
||||||
{formatCurrency(String(value))}
|
{formatCurrency(String(value))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -645,9 +608,7 @@ export function DigitalInnovationPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return <span className="font-medium text-white">{String(value)}</span>;
|
||||||
<span className="font-light text-sm text-white">{String(value)}</span>
|
|
||||||
);
|
|
||||||
case "project_status":
|
case "project_status":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -682,7 +643,7 @@ export function DigitalInnovationPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout title="نوآوری دیجیتال">
|
<DashboardLayout title="نوآوری دیجیتال">
|
||||||
<div className="space-y-4 grid justify-between gap-7 pl-6 sm:grid-cols-1 xl:grid-cols-[40%_60%]">
|
<div className="p-6 space-y-4 grid justify-between gap-8 sm:grid-cols-1 xl:grid-cols-[40%_60%]">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="flex flex-col gap-6 w-full mb-0">
|
<div className="flex flex-col gap-6 w-full mb-0">
|
||||||
<div className="space-y-6 w-full">
|
<div className="space-y-6 w-full">
|
||||||
|
|
@ -739,7 +700,7 @@ export function DigitalInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center flex-col p-2 pb-4">
|
<div className="flex items-center justify-center flex-col p-2 pb-4">
|
||||||
<p
|
<p
|
||||||
className={`text-3xl font-bold ${card.color} mb-1`}
|
className={`text-3xl font-bold ${card.color} mb-1`}
|
||||||
>
|
>
|
||||||
{card.value}
|
{card.value}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -755,49 +716,50 @@ export function DigitalInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Process Impacts Chart */}
|
{/* Process Impacts Chart */}
|
||||||
<BaseCard className="rounded-xl w-full overflow-hidden">
|
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-lg w-full overflow-hidden h-full ">
|
||||||
{/* <CardContent > */}
|
{/* <CardContent > */}
|
||||||
<CustomBarChart
|
<CustomBarChart
|
||||||
title="تاثیرات نوآوری دیجیتال به صورت درصد مقایسه ای"
|
title="تاثیرات نوآوری دیجیتال به صورت درصد مقایسه ای"
|
||||||
loading={statsLoading}
|
loading={statsLoading}
|
||||||
// height="100%"
|
height="100%"
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
label: DigitalCardLabel.decreasCost,
|
label: DigitalCardLabel.decreasCost,
|
||||||
value: stats.reduceCostsPercent || 0,
|
value: stats.reduceCostsPercent || 0,
|
||||||
color: "bg-pr-green",
|
color: "bg-emerald-400",
|
||||||
labelColor: "text-white",
|
labelColor: "text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: DigitalCardLabel.increaseRevenue,
|
label: DigitalCardLabel.increaseRevenue,
|
||||||
value: stats.increasedRevenuePercent || 0,
|
value: stats.increasedRevenuePercent || 0,
|
||||||
color: "bg-pr-green",
|
color: "bg-emerald-400",
|
||||||
labelColor: "text-white",
|
labelColor: "text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: DigitalCardLabel.performance,
|
label: DigitalCardLabel.performance,
|
||||||
value: stats.resourceProductivityPercent || 0,
|
value: stats.resourceProductivityPercent || 0,
|
||||||
color: "bg-pr-green",
|
color: "bg-emerald-400",
|
||||||
labelColor: "text-white",
|
labelColor: "text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: DigitalCardLabel.decreaseEnergy,
|
label: DigitalCardLabel.decreaseEnergy,
|
||||||
value: stats.reduceEnergyConsumptionPercent || 0,
|
value: stats.reduceEnergyConsumptionPercent || 0,
|
||||||
color: "bg-pr-green",
|
color: "bg-emerald-400",
|
||||||
labelColor: "text-white",
|
labelColor: "text-white",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
barHeight="h-5"
|
barHeight="h-5"
|
||||||
showAxisLabels={true}
|
showAxisLabels={true}
|
||||||
/>
|
/>
|
||||||
</BaseCard>
|
{/* </CardContent> */}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max">
|
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-[39.7rem]">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<Table containerClassName="overflow-auto custom-scrollbar w-full h-[calc(100vh-160px)] ">
|
<Table containerClassName="overflow-auto custom-scrollbar w-full h-[36.8rem] ">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-[#3F415A]">
|
<TableRow className="bg-[#3F415A]">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|
@ -976,13 +938,13 @@ export function DigitalInnovationPage() {
|
||||||
|
|
||||||
{/* Project Details Dialog */}
|
{/* Project Details Dialog */}
|
||||||
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
||||||
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-6xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-5xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
|
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
|
||||||
شرح پروژه
|
شرح پروژه
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="body grid grid-cols-[40%_20%_40%] pb-6">
|
<div className="body grid grid-cols-[40%_20%_40%]">
|
||||||
<div className="border-l-2 border-l-gray-600 px-6">
|
<div className="border-l-2 border-l-gray-600 px-6">
|
||||||
<span className="title text-lg font-bold">
|
<span className="title text-lg font-bold">
|
||||||
{dialogInfo?.title}
|
{dialogInfo?.title}
|
||||||
|
|
@ -1034,7 +996,7 @@ export function DigitalInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="digitalAbilityDevelopment flex flex-col gap-10 border-l-2 border-l-gray-600 px-5">
|
<div className="digitalAbilityDevelopment flex flex-col gap-10 border-l-2 border-l-gray-600 px-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="text-lg font-bold">
|
<span className="text-md font-bold">
|
||||||
توسعه قابلیت های دیجیتال:{" "}
|
توسعه قابلیت های دیجیتال:{" "}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -1097,10 +1059,10 @@ export function DigitalInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-6 gap-4">
|
<div className="flex flex-col pr-7 gap-4">
|
||||||
<div className="costBoard mx-auto w-full">
|
<div className="costBoard mx-auto w-full">
|
||||||
<div className="board o border border-gray-600 rounded-xl overflow-hidden flex flex-col">
|
<div className="board o border border-gray-600 rounded-xl overflow-hidden flex flex-col">
|
||||||
<span className="text-sm bg-[#3F415A] text-white w-full p-2.5 pr-4 ">
|
<span className="title bg-[#3F415A] text-white w-full p-2.5 pr-4 ">
|
||||||
کاهش هزینه ها
|
کاهش هزینه ها
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// import moment from "moment-jalaali";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
|
|
@ -25,7 +26,6 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { EventBus, formatNumber } from "~/lib/utils";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
|
@ -42,17 +42,12 @@ import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import moment from "moment-jalaali";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { MetricCard } from "~/components/ui/metric-card";
|
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { formatCurrency } from "~/lib/utils";
|
import { formatCurrency } from "~/lib/utils";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
import DashboardLayout from "../layout";
|
import DashboardLayout from "../layout";
|
||||||
|
|
||||||
moment.loadPersian({ usePersianDigits: true });
|
// moment.loadPersian({ usePersianDigits: true });
|
||||||
|
|
||||||
interface GreenInnovationData {
|
interface GreenInnovationData {
|
||||||
WorkflowID: string;
|
WorkflowID: string;
|
||||||
approved_budget: string;
|
approved_budget: string;
|
||||||
|
|
@ -170,8 +165,6 @@ export function GreenInnovationPage() {
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [actualTotalCount, setActualTotalCount] = useState(0);
|
const [actualTotalCount, setActualTotalCount] = useState(0);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<stateCounter>();
|
const [stats, setStats] = useState<stateCounter>();
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
field: "start_date",
|
field: "start_date",
|
||||||
|
|
@ -189,14 +182,14 @@ export function GreenInnovationPage() {
|
||||||
useState<GreenInnovationData | null>(null);
|
useState<GreenInnovationData | null>(null);
|
||||||
const [recycleParams, setRecycleParams] = useState<RecycleParams>({
|
const [recycleParams, setRecycleParams] = useState<RecycleParams>({
|
||||||
water: {
|
water: {
|
||||||
icon: <Key className="text-success" size={"18px"} />,
|
icon: <Key className="text-emerald-400" size={"18px"} />,
|
||||||
label: "آب",
|
label: "آب",
|
||||||
value: 0,
|
value: 0,
|
||||||
suffix: "لیتر",
|
suffix: "لیتر",
|
||||||
percent: 0,
|
percent: 0,
|
||||||
},
|
},
|
||||||
food: {
|
food: {
|
||||||
icon: <Sparkle className="text-success" size={"18px"} />,
|
icon: <Sparkle className="text-emerald-400" size={"18px"} />,
|
||||||
label: "خوراک",
|
label: "خوراک",
|
||||||
value: 0,
|
value: 0,
|
||||||
suffix: "تن",
|
suffix: "تن",
|
||||||
|
|
@ -204,14 +197,14 @@ export function GreenInnovationPage() {
|
||||||
},
|
},
|
||||||
|
|
||||||
power: {
|
power: {
|
||||||
icon: <Zap className="text-success" size={"18px"} />,
|
icon: <Zap className="text-emerald-400" size={"18px"} />,
|
||||||
label: "برق",
|
label: "برق",
|
||||||
value: 0,
|
value: 0,
|
||||||
suffix: "میلیون مگاوات",
|
suffix: "میلیون مگاوات",
|
||||||
percent: 0,
|
percent: 0,
|
||||||
},
|
},
|
||||||
oil: {
|
oil: {
|
||||||
icon: <Flame className="text-success" size={"18px"} />,
|
icon: <Flame className="text-emerald-400" size={"18px"} />,
|
||||||
label: "سوخت",
|
label: "سوخت",
|
||||||
value: 0,
|
value: 0,
|
||||||
suffix: "متر مربع",
|
suffix: "متر مربع",
|
||||||
|
|
@ -263,7 +256,11 @@ export function GreenInnovationPage() {
|
||||||
setDetailsDialogOpen(true);
|
setDetailsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...existing code...
|
const formatNumber = (value: string | number) => {
|
||||||
|
const numericValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (isNaN(numericValue)) return "0";
|
||||||
|
return new Intl.NumberFormat("fa-IR").format(numericValue);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchProjects = async (reset = false) => {
|
const fetchProjects = async (reset = false) => {
|
||||||
if (fetchingRef.current) {
|
if (fetchingRef.current) {
|
||||||
|
|
@ -294,11 +291,7 @@ export function GreenInnovationPage() {
|
||||||
"observer",
|
"observer",
|
||||||
],
|
],
|
||||||
Sorts: [[sortConfig.field, sortConfig.direction]],
|
Sorts: [[sortConfig.field, sortConfig.direction]],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری سبز"]],
|
||||||
["type_of_innovation", "=", "نوآوری سبز", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||||
});
|
});
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -360,34 +353,20 @@ export function GreenInnovationPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (date: CalendarDate) => {
|
|
||||||
if (date) setDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasMore && !loading) {
|
if (!loadingMore && hasMore && !loading) {
|
||||||
setCurrentPage((prev) => prev + 1);
|
setCurrentPage((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [hasMore, loading]);
|
}, [loadingMore, hasMore, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date.end && date.start) {
|
fetchProjects(true);
|
||||||
fetchProjects(true);
|
fetchTotalCount();
|
||||||
fetchTotalCount();
|
}, [sortConfig]);
|
||||||
}
|
|
||||||
}, [sortConfig, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date.end && date.start) fetchStats();
|
fetchStats();
|
||||||
}, [selectedProjects, date]);
|
}, [selectedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
|
|
@ -399,12 +378,12 @@ export function GreenInnovationPage() {
|
||||||
const scrollContainer = document.querySelector(".overflow-auto");
|
const scrollContainer = document.querySelector(".overflow-auto");
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollContainer || !hasMore) return;
|
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
if (scrollPercentage == 1) {
|
if (scrollPercentage >= 0.9) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -440,11 +419,7 @@ export function GreenInnovationPage() {
|
||||||
const response = await apiService.select({
|
const response = await apiService.select({
|
||||||
ProcessName: "project",
|
ProcessName: "project",
|
||||||
OutputFields: ["count(project_no)"],
|
OutputFields: ["count(project_no)"],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری سبز"]],
|
||||||
["type_of_innovation", "=", "نوآوری سبز", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
const dataString = response.data;
|
const dataString = response.data;
|
||||||
|
|
@ -476,8 +451,6 @@ export function GreenInnovationPage() {
|
||||||
selectedProjects.size > 0
|
selectedProjects.size > 0
|
||||||
? Array.from(selectedProjects).join(" , ")
|
? Array.from(selectedProjects).join(" , ")
|
||||||
: "",
|
: "",
|
||||||
start_date: date?.start || null,
|
|
||||||
end_date: date?.end || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let payload: any = raw?.data;
|
let payload: any = raw?.data;
|
||||||
|
|
@ -524,13 +497,13 @@ export function GreenInnovationPage() {
|
||||||
},
|
},
|
||||||
|
|
||||||
pollution: {
|
pollution: {
|
||||||
value: parseNum(stats.pollution_reduction),
|
value: formatNumber(parseNum(stats.pollution_reduction)),
|
||||||
percent: parseNum(stats.pollution_reduction_percent),
|
percent: formatNumber(parseNum(stats.pollution_reduction_percent)),
|
||||||
},
|
},
|
||||||
|
|
||||||
waste: {
|
waste: {
|
||||||
value: parseNum(stats.waste_reduction),
|
value: formatNumber(parseNum(stats.waste_reduction)),
|
||||||
percent: parseNum(stats.waste_reductionn_percent),
|
percent: formatNumber(parseNum(stats.waste_reductionn_percent)),
|
||||||
},
|
},
|
||||||
avarage: stats.average_project_score,
|
avarage: stats.average_project_score,
|
||||||
countInnovationGreenProjects: stats.count_innovation_green_projects,
|
countInnovationGreenProjects: stats.count_innovation_green_projects,
|
||||||
|
|
@ -548,6 +521,7 @@ export function GreenInnovationPage() {
|
||||||
setStatsLoading(false);
|
setStatsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPageData = (normalized: any) => {
|
const setPageData = (normalized: any) => {
|
||||||
setSustainabilityStats((prev) => ({
|
setSustainabilityStats((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -631,14 +605,14 @@ export function GreenInnovationPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleProjectDetails(item)}
|
onClick={() => handleProjectDetails(item)}
|
||||||
className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
|
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer"
|
||||||
>
|
>
|
||||||
جزئیات بیشتر
|
جزئیات بیشتر
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case "amount_currency_reduction":
|
case "amount_currency_reduction":
|
||||||
return (
|
return (
|
||||||
<span className="font-medium text-pr-green">
|
<span className="font-medium text-emerald-400">
|
||||||
{formatCurrency(String(value))}
|
{formatCurrency(String(value))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -649,9 +623,7 @@ export function GreenInnovationPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return <span className="font-medium text-white">{String(value)}</span>;
|
||||||
<span className="font-light text-sm text-white">{String(value)}</span>
|
|
||||||
);
|
|
||||||
case "project_status":
|
case "project_status":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -717,7 +689,7 @@ export function GreenInnovationPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout title="نوآوری سبز">
|
<DashboardLayout title="نوآوری سبز">
|
||||||
<div className="space-y-4 h-[23.5rem]">
|
<div className="p-6 space-y-4 h-[23.5rem]">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="flex gap-6 mb-5 md:flex-col xl:flex-row">
|
<div className="flex gap-6 mb-5 md:flex-col xl:flex-row">
|
||||||
<div className="flex flex-col justify-between xl:w-1/2 sm:w-full sm:gap-2">
|
<div className="flex flex-col justify-between xl:w-1/2 sm:w-full sm:gap-2">
|
||||||
|
|
@ -751,14 +723,39 @@ export function GreenInnovationPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
: Object.entries(sustainabilityStats).map(([key, value]) => (
|
: Object.entries(sustainabilityStats).map(([key, value]) => (
|
||||||
<MetricCard
|
<Card
|
||||||
key={key}
|
key={key}
|
||||||
title={value.title}
|
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50"
|
||||||
value={Math.round(value.total.value || 0)}
|
>
|
||||||
valueLabel={value.total?.description}
|
<CardContent className="p-0 h-full">
|
||||||
percentValue={value.percent?.value || 0}
|
<div className="flex flex-col justify-between gap-2 h-full">
|
||||||
percentLabel={value.percent?.description}
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -819,10 +816,7 @@ export function GreenInnovationPage() {
|
||||||
<div className="params flex flex-col gap-3.5">
|
<div className="params flex flex-col gap-3.5">
|
||||||
{Object.entries(recycleParams).map((el, index) => {
|
{Object.entries(recycleParams).map((el, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="param flex flex-row justify-between items-center">
|
||||||
key={index}
|
|
||||||
className="param flex flex-row justify-between items-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{el[1].icon}
|
{el[1].icon}
|
||||||
<span className="font-normal text-sm font-persian">
|
<span className="font-normal text-sm font-persian">
|
||||||
|
|
@ -904,7 +898,7 @@ export function GreenInnovationPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="w-1/2 bg-pr-gray backdrop-blur-sm rounded-lg overflow-hidden">
|
<Card className="w-1/3 bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-lg overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="border-b-2 border-gray-500/20">
|
<div className="border-b-2 border-gray-500/20">
|
||||||
<div className="flex flex-row justify-between w-full p-4">
|
<div className="flex flex-row justify-between w-full p-4">
|
||||||
|
|
@ -955,7 +949,7 @@ export function GreenInnovationPage() {
|
||||||
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden">
|
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Table containerClassName="overflow-auto custom-scrollbar h-full">
|
<Table containerClassName="overflow-auto custom-scrollbar h-[25rem]">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-[#3F415A]">
|
<TableRow className="bg-[#3F415A]">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|
@ -1119,7 +1113,7 @@ export function GreenInnovationPage() {
|
||||||
شرح پروژه
|
شرح پروژه
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 flex justify-between text-right p-6">
|
<div className="space-y-4 flex justify-between text-right px-6">
|
||||||
{/* Project Description */}
|
{/* Project Description */}
|
||||||
<div className="flex-[4] border-l-2 border-gray-600">
|
<div className="flex-[4] border-l-2 border-gray-600">
|
||||||
<h2 className="font-bold">{selectedProjectDetails?.title}</h2>
|
<h2 className="font-bold">{selectedProjectDetails?.title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// ...existing code...
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -39,11 +38,8 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { MetricCard } from "~/components/ui/metric-card";
|
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { EventBus, formatCurrency, formatNumber } from "~/lib/utils";
|
import { formatCurrency } from "~/lib/utils";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
import DashboardLayout from "../layout";
|
import DashboardLayout from "../layout";
|
||||||
|
|
||||||
interface innovationBuiltInDate {
|
interface innovationBuiltInDate {
|
||||||
|
|
@ -155,8 +151,8 @@ enum projectStatus {
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: "select", label: "", sortable: false, width: "50px" },
|
{ key: "select", label: "", sortable: false, width: "50px" },
|
||||||
{ key: "project_no", label: "شماره پروژه", sortable: true, width: "120px" },
|
{ key: "project_no", label: "شماره پروژه", sortable: true, width: "140px" },
|
||||||
{ key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
|
{ key: "title", label: "عنوان پروژه", sortable: true, width: "400px" },
|
||||||
{
|
{
|
||||||
key: "project_status",
|
key: "project_status",
|
||||||
label: "وضعیت پروژه",
|
label: "وضعیت پروژه",
|
||||||
|
|
@ -167,7 +163,7 @@ const columns = [
|
||||||
key: "project_rating",
|
key: "project_rating",
|
||||||
label: "امتیاز پروژه",
|
label: "امتیاز پروژه",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "120px",
|
width: "140px",
|
||||||
},
|
},
|
||||||
{ key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" },
|
{ key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" },
|
||||||
];
|
];
|
||||||
|
|
@ -194,8 +190,6 @@ export function InnovationBuiltInsidePage() {
|
||||||
field: "start_date",
|
field: "start_date",
|
||||||
direction: "asc",
|
direction: "asc",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
const [tblAvarage, setTblAvarage] = useState<number>(0);
|
const [tblAvarage, setTblAvarage] = useState<number>(0);
|
||||||
const [selectedProjects, setSelectedProjects] =
|
const [selectedProjects, setSelectedProjects] =
|
||||||
useState<Set<string | number>>();
|
useState<Set<string | number>>();
|
||||||
|
|
@ -281,7 +275,12 @@ export function InnovationBuiltInsidePage() {
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...existing code...
|
const formatNumber = (value: string | number) => {
|
||||||
|
if (!value) return "0";
|
||||||
|
const numericValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (isNaN(numericValue)) return "0";
|
||||||
|
return new Intl.NumberFormat("fa-IR").format(numericValue);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchProjects = async (reset = false) => {
|
const fetchProjects = async (reset = false) => {
|
||||||
if (fetchingRef.current) {
|
if (fetchingRef.current) {
|
||||||
|
|
@ -315,11 +314,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
"technology_maturity_level",
|
"technology_maturity_level",
|
||||||
],
|
],
|
||||||
Sorts: [[sortConfig.field, sortConfig.direction]],
|
Sorts: [[sortConfig.field, sortConfig.direction]],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری ساخت داخل"]],
|
||||||
["type_of_innovation", "=", "نوآوری ساخت داخل", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||||
});
|
});
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -420,30 +415,18 @@ export function InnovationBuiltInsidePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasMore && !loading) {
|
if (!loadingMore && hasMore && !loading) {
|
||||||
setCurrentPage((prev) => prev + 1);
|
setCurrentPage((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [hasMore, loading]);
|
}, [loadingMore, hasMore, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (date: CalendarDate) => {
|
fetchProjects(true);
|
||||||
if (date) setDate(date);
|
}, [sortConfig]);
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date.start && date.end) fetchProjects(true);
|
fetchStats();
|
||||||
}, [sortConfig, date]);
|
}, [selectedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (date.end && date.start) fetchStats();
|
|
||||||
}, [selectedProjects, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
|
|
@ -455,12 +438,12 @@ export function InnovationBuiltInsidePage() {
|
||||||
const scrollContainer = document.querySelector(".overflow-auto");
|
const scrollContainer = document.querySelector(".overflow-auto");
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollContainer || !hasMore) return;
|
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
if (scrollPercentage == 1) {
|
if (scrollPercentage >= 0.9) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -501,8 +484,6 @@ export function InnovationBuiltInsidePage() {
|
||||||
selectedProjects && selectedProjects?.size > 0
|
selectedProjects && selectedProjects?.size > 0
|
||||||
? Array.from(selectedProjects).join(" , ")
|
? Array.from(selectedProjects).join(" , ")
|
||||||
: "",
|
: "",
|
||||||
start_date: date?.start || null,
|
|
||||||
end_date: date?.end || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let payload: any = raw?.data;
|
let payload: any = raw?.data;
|
||||||
|
|
@ -528,13 +509,15 @@ export function InnovationBuiltInsidePage() {
|
||||||
const stats = data[0];
|
const stats = data[0];
|
||||||
const normalized: any = {
|
const normalized: any = {
|
||||||
currencySaving: {
|
currencySaving: {
|
||||||
value: parseNum(stats?.foreign_currency_saving),
|
value: formatNumber(parseNum(stats?.foreign_currency_saving)),
|
||||||
percent: parseNum(stats?.foreign_currency_saving_percent),
|
percent: formatNumber(
|
||||||
|
parseNum(stats?.foreign_currency_saving_percent)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
investmentAmount: {
|
investmentAmount: {
|
||||||
value: parseNum(stats?.investment_amount),
|
value: formatNumber(parseNum(stats?.investment_amount)),
|
||||||
percent: parseNum(stats?.investment_amount_percent),
|
percent: formatNumber(parseNum(stats?.investment_amount_percent)),
|
||||||
},
|
},
|
||||||
|
|
||||||
technology: {
|
technology: {
|
||||||
|
|
@ -645,14 +628,14 @@ export function InnovationBuiltInsidePage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleProjectDetails(item)}
|
onClick={() => handleProjectDetails(item)}
|
||||||
className="text-pr-green hover:text-pr-green underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto"
|
className="text-emerald-500 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto cursor-pointer"
|
||||||
>
|
>
|
||||||
جزئیات بیشتر
|
جزئیات بیشتر
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case "amount_currency_reduction":
|
case "amount_currency_reduction":
|
||||||
return (
|
return (
|
||||||
<span className="font-medium text-pr-green">
|
<span className="font-medium text-emerald-500">
|
||||||
{formatCurrency(String(value))}
|
{formatCurrency(String(value))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -663,9 +646,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return <span className="font-medium text-white">{String(value)}</span>;
|
||||||
<span className="font-light text-sm text-white">{String(value)}</span>
|
|
||||||
);
|
|
||||||
case "project_status":
|
case "project_status":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -724,10 +705,10 @@ export function InnovationBuiltInsidePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout title="نوآوری ساخت داخل">
|
<DashboardLayout title="نوآوری ساخت داخل">
|
||||||
<div className="space-y-4 justify-between gap-8 grid pl-6 sm:grid-cols-1 xl:grid-cols-[35%_65%]">
|
<div className="p-6 space-y-4 justify-between gap-8 grid sm:grid-cols-1 xl:grid-cols-[40%_60%]">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="flex w-full mb-0">
|
<div className="flex gap-6 w-full mb-0">
|
||||||
<div className="flex flex-col w-full justify-between gap-2">
|
<div className="flex flex-col justify-between w-full gap-6">
|
||||||
{statsLoading
|
{statsLoading
|
||||||
? // Loading skeleton for stats cards - matching new design
|
? // Loading skeleton for stats cards - matching new design
|
||||||
Array.from({ length: 2 }).map((_, index) => (
|
Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
|
@ -758,47 +739,39 @@ export function InnovationBuiltInsidePage() {
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
: Object.entries(sustainabilityStats).map(([key, value]) => (
|
: Object.entries(sustainabilityStats).map(([key, value]) => (
|
||||||
<MetricCard
|
<Card
|
||||||
key={key}
|
key={key}
|
||||||
title={value.title}
|
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50"
|
||||||
value={Math.round(value.total.value || 0)}
|
>
|
||||||
valueLabel={value.total?.description}
|
<CardContent className="p-0 h-full">
|
||||||
percentValue={value.percent?.value || 0}
|
<div className="flex flex-col justify-between gap-2 h-full">
|
||||||
percentLabel={value.percent?.description}
|
<div className="flex justify-between items-center border-b-2 border-gray-500/20 ">
|
||||||
/>
|
<h3 className="text-lg font-semibold text-white p-4">
|
||||||
// <Card
|
{value.title}
|
||||||
// key={key}
|
</h3>
|
||||||
// className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] rounded-lg backdrop-blur-sm border-gray-700/50"
|
</div>
|
||||||
// >
|
<div className="flex items-center justify-between p-6 flex-row-reverse">
|
||||||
// <CardContent className="p-0 h-full">
|
<div className="flex flex-col">
|
||||||
// <div className="flex flex-col justify-between gap-2 h-full">
|
<span className="text-3xl font-bold text-emerald-500 mb-1 font-persian">
|
||||||
// <div className="flex justify-between items-center border-b-2 border-gray-500/20 ">
|
% {value.percent?.value}
|
||||||
// <h3 className="text-lg font-semibold text-white p-4">
|
</span>
|
||||||
// {value.title}
|
<span className="text-sm text-gray-400 font-persian">
|
||||||
// </h3>
|
{value.percent?.description}
|
||||||
// </div>
|
</span>
|
||||||
// <div className="flex items-center justify-between p-6 flex-row-reverse">
|
</div>
|
||||||
// <div className="flex flex-col">
|
<b className="block w-0.5 h-8 bg-gray-600 rotate-45" />
|
||||||
// <span className="text-3xl font-bold text-pr-green mb-1 font-persian">
|
<div className="flex flex-col">
|
||||||
// % {value.percent?.value}
|
<span className="text-3xl font-bold text-emerald-500 mb-1 font-persian">
|
||||||
// </span>
|
{value.total?.value}
|
||||||
// <span className="text-sm text-gray-400 font-persian">
|
</span>
|
||||||
// {value.percent?.description}
|
<span className="text-sm text-gray-400 font-persian">
|
||||||
// </span>
|
{value.total?.description}
|
||||||
// </div>
|
</span>
|
||||||
// <b className="block w-0.5 h-8 bg-gray-600 rotate-45" />
|
</div>
|
||||||
// <div className="flex flex-col">
|
</div>
|
||||||
// <span className="text-3xl font-bold text-pr-green mb-1 font-persian">
|
</div>
|
||||||
// {value.total?.value}
|
</CardContent>
|
||||||
// </span>
|
</Card>
|
||||||
// <span className="text-sm text-gray-400 font-persian">
|
|
||||||
// {value.total?.description}
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </CardContent>
|
|
||||||
// </Card>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
|
|
@ -898,7 +871,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max">
|
<Card className="bg-transparent backdrop-blur-sm rounded-lg overflow-hidden w-full h-max">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative ">
|
<div className="relative ">
|
||||||
<Table containerClassName="overflow-auto custom-scrollbar h-[calc(100vh-160px)]">
|
<Table containerClassName="overflow-auto custom-scrollbar h-[calc(90vh-15px)]">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-[#3F415A]">
|
<TableRow className="bg-[#3F415A]">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|
@ -1061,7 +1034,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
|
|
||||||
{/* Project Details Dialog */}
|
{/* Project Details Dialog */}
|
||||||
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
||||||
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-5xl overflow-y-auto">
|
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-6xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
|
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
|
||||||
شرح پروژه
|
شرح پروژه
|
||||||
|
|
@ -1123,6 +1096,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
<div className="flex flex-col justify-center items-center">
|
<div className="flex flex-col justify-center items-center">
|
||||||
<span className="block w-0.5 h-14 bg-white"></span>
|
<span className="block w-0.5 h-14 bg-white"></span>
|
||||||
<span className="text-white border border-white p-1 px-2 text-xs rounded-lg">
|
<span className="text-white border border-white p-1 px-2 text-xs rounded-lg">
|
||||||
|
{" "}
|
||||||
سطح تکنولوژی
|
سطح تکنولوژی
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1286,7 +1260,7 @@ export function InnovationBuiltInsidePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={420}>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={dialogChartData}
|
data={dialogChartData}
|
||||||
margin={{ top: 20, right: 70, left: 30, bottom: 80 }}
|
margin={{ top: 20, right: 70, left: 30, bottom: 80 }}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,7 +15,6 @@ import moment from "moment-jalaali";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { BaseCard } from "~/components/ui/base-card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
|
@ -34,10 +33,8 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { EventBus, formatNumber } from "~/lib/utils";
|
import { formatNumber } from "~/lib/utils";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
import { DashboardLayout } from "../layout";
|
import { DashboardLayout } from "../layout";
|
||||||
|
|
||||||
moment.loadPersian({ usePersianDigits: true });
|
moment.loadPersian({ usePersianDigits: true });
|
||||||
|
|
@ -52,11 +49,6 @@ interface ProcessInnovationData {
|
||||||
amount_currency_reduction: string;
|
amount_currency_reduction: string;
|
||||||
Reduce_rate_failure: string;
|
Reduce_rate_failure: string;
|
||||||
observer: string;
|
observer: string;
|
||||||
// optional detailed fields returned by API
|
|
||||||
project_description?: string;
|
|
||||||
start_date?: string;
|
|
||||||
done_date?: string;
|
|
||||||
approved_budget?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectStats {
|
interface ProjectStats {
|
||||||
|
|
@ -67,11 +59,9 @@ interface ProjectStats {
|
||||||
percent_reduction_value_currency: string;
|
percent_reduction_value_currency: string;
|
||||||
percent_sum_stopping_production: string;
|
percent_sum_stopping_production: string;
|
||||||
percent_throat_removal: string;
|
percent_throat_removal: string;
|
||||||
percent_operating_cost_before_innovation: string;
|
|
||||||
sum_reducing_breakdowns: number;
|
sum_reducing_breakdowns: number;
|
||||||
sum_reduction_value_currency: number;
|
sum_reduction_value_currency: number;
|
||||||
sum_stopping_production: number;
|
sum_stopping_production: number;
|
||||||
sum_operating_cost_reduction: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortConfig {
|
interface SortConfig {
|
||||||
|
|
@ -96,11 +86,9 @@ interface InnovationStats {
|
||||||
currencyReductionSum: number; // مجموع کاهش ارز بری (میلیون ریال)
|
currencyReductionSum: number; // مجموع کاهش ارز بری (میلیون ریال)
|
||||||
frequentFailuresReductionSum: number; // مجموع کاهش خرابی های پرتکرار
|
frequentFailuresReductionSum: number; // مجموع کاهش خرابی های پرتکرار
|
||||||
percentProductionStops: number | string; // درصد مقایسهای جلوگیری از توقفات تولید
|
percentProductionStops: number | string; // درصد مقایسهای جلوگیری از توقفات تولید
|
||||||
reductionCostOprationSum: number; // مجموع کاهش هزینه عملیاتی
|
|
||||||
percentBottleneckRemoval: number | string; // درصد مقایسهای رفع گلوگاه
|
percentBottleneckRemoval: number | string; // درصد مقایسهای رفع گلوگاه
|
||||||
percentCurrencyReduction: number | string; // درصد مقایسهای کاهش ارز بری
|
percentCurrencyReduction: number | string; // درصد مقایسهای کاهش ارز بری
|
||||||
percentFailuresReduction: number | string; // درصد مقایسهای کاهش خرابیهای پرتکرار
|
percentFailuresReduction: number | string; // درصد مقایسهای کاهش خرابیهای پرتکرار
|
||||||
percentOperatingCostBeforeInnovation: number | string; // درصد مقایسهای کاهش هزینه عملیاتی
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|
@ -129,14 +117,13 @@ export function ProcessInnovationPage() {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize] = useState(20);
|
const [pageSize] = useState(20);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [date, setDate] = useStoredDate();
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [actualTotalCount, setActualTotalCount] = useState(0);
|
const [actualTotalCount, setActualTotalCount] = useState(0);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [stats, setStats] = useState<InnovationStats>({
|
const [stats, setStats] = useState<InnovationStats>({
|
||||||
totalProjects: 0,
|
totalProjects: 0,
|
||||||
averageScore: 0,
|
averageScore: 0,
|
||||||
productionStopsPreventionSum: 0,
|
productionStopsPreventionSum: 0,
|
||||||
reductionCostOprationSum: 0,
|
|
||||||
bottleneckRemovalCount: 0,
|
bottleneckRemovalCount: 0,
|
||||||
currencyReductionSum: 0,
|
currencyReductionSum: 0,
|
||||||
frequentFailuresReductionSum: 0,
|
frequentFailuresReductionSum: 0,
|
||||||
|
|
@ -144,7 +131,6 @@ export function ProcessInnovationPage() {
|
||||||
percentBottleneckRemoval: 0,
|
percentBottleneckRemoval: 0,
|
||||||
percentCurrencyReduction: 0,
|
percentCurrencyReduction: 0,
|
||||||
percentFailuresReduction: 0,
|
percentFailuresReduction: 0,
|
||||||
percentOperatingCostBeforeInnovation: 0,
|
|
||||||
});
|
});
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
field: "start_date",
|
field: "start_date",
|
||||||
|
|
@ -160,60 +146,58 @@ export function ProcessInnovationPage() {
|
||||||
const [stateCard, setStateCard] = useState({
|
const [stateCard, setStateCard] = useState({
|
||||||
productionstopsprevention: {
|
productionstopsprevention: {
|
||||||
id: "productionstopsprevention",
|
id: "productionstopsprevention",
|
||||||
title: "توقفات تولید",
|
title: "جلوگیری از توقفات تولید",
|
||||||
value: formatNumber(
|
value: formatNumber(
|
||||||
stats.productionStopsPreventionSum.toFixed?.(1) ??
|
stats.productionStopsPreventionSum.toFixed?.(1) ??
|
||||||
stats.productionStopsPreventionSum
|
stats.productionStopsPreventionSum
|
||||||
),
|
),
|
||||||
description: "تن افزایش یافته",
|
description: "تن افزایش یافته",
|
||||||
icon: CirclePause,
|
icon: <CirclePause />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
bottleneckremoval: {
|
bottleneckremoval: {
|
||||||
id: "bottleneckremoval",
|
id: "bottleneckremoval",
|
||||||
title: "گلوگاه ها",
|
title: "رفع گلوگاه",
|
||||||
value: formatNumber(stats.bottleneckRemovalCount),
|
value: formatNumber(stats.bottleneckRemovalCount),
|
||||||
description: "تعداد رفع گلوگاه",
|
description: "تعداد رفع گلوگاه",
|
||||||
icon: Funnel,
|
icon: <Funnel />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
currencyreduction: {
|
currencyreduction: {
|
||||||
id: "currencyreduction",
|
id: "currencyreduction",
|
||||||
title: "ارز بری",
|
title: "کاهش ارز بری",
|
||||||
value: formatNumber(
|
value: formatNumber(
|
||||||
stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum
|
stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum
|
||||||
),
|
),
|
||||||
description: "دلار کاهش یافته",
|
description: "دلار کاهش یافته",
|
||||||
icon: DollarSign,
|
icon: <DollarSign />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
|
||||||
decreaseCurrencyOperation: {
|
|
||||||
id: "decreaseCurrencyOperation",
|
|
||||||
title: "هزینه های عملیاتی",
|
|
||||||
value: formatNumber(
|
|
||||||
stats.reductionCostOprationSum.toFixed?.(0) ??
|
|
||||||
stats.reductionCostOprationSum
|
|
||||||
),
|
|
||||||
description: "میلیون ریال کاهش یافته",
|
|
||||||
icon: DollarSign,
|
|
||||||
color: "text-pr-green",
|
|
||||||
},
|
},
|
||||||
frequentfailuresreduction: {
|
frequentfailuresreduction: {
|
||||||
id: "frequentfailuresreduction",
|
id: "frequentfailuresreduction",
|
||||||
title: "خرابی های پرتکرار",
|
title: "کاهش خرابی های پرتکرار",
|
||||||
value: formatNumber(
|
value: formatNumber(
|
||||||
stats.frequentFailuresReductionSum.toFixed?.(1) ??
|
stats.frequentFailuresReductionSum.toFixed?.(1) ??
|
||||||
stats.frequentFailuresReductionSum
|
stats.frequentFailuresReductionSum
|
||||||
),
|
),
|
||||||
description: "خرابی پر تکرار کاهش یافته",
|
description: "مجموع درصد کاهش خرابی",
|
||||||
icon: Wrench,
|
icon: <Wrench />,
|
||||||
color: "text-pr-green",
|
color: "text-emerald-400",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const observerRef = useRef<HTMLDivElement>(null);
|
const observerRef = useRef<HTMLDivElement>(null);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
// Selection handlers
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedProjects.size === projects.length) {
|
||||||
|
setSelectedProjects(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedProjects(new Set(projects.map((p) => p.project_no)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectProject = (projectNo: string) => {
|
const handleSelectProject = (projectNo: string) => {
|
||||||
const newSelected = new Set(selectedProjects);
|
const newSelected = new Set(selectedProjects);
|
||||||
if (newSelected.has(projectNo)) {
|
if (newSelected.has(projectNo)) {
|
||||||
|
|
@ -266,14 +250,11 @@ export function ProcessInnovationPage() {
|
||||||
"observer",
|
"observer",
|
||||||
],
|
],
|
||||||
Sorts: [["start_date", "asc"]],
|
Sorts: [["start_date", "asc"]],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]],
|
||||||
["type_of_innovation", "=", "نوآوری در فرآیند", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(JSON.parse(response.data));
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
const dataString = response.data;
|
const dataString = response.data;
|
||||||
if (dataString && typeof dataString === "string") {
|
if (dataString && typeof dataString === "string") {
|
||||||
|
|
@ -282,16 +263,16 @@ export function ProcessInnovationPage() {
|
||||||
if (Array.isArray(parsedData)) {
|
if (Array.isArray(parsedData)) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects(parsedData);
|
setProjects(parsedData);
|
||||||
// setTotalCount(parsedData.length);
|
setTotalCount(parsedData.length);
|
||||||
} else {
|
} else {
|
||||||
setProjects((prev) => [...prev, ...parsedData]);
|
setProjects((prev) => [...prev, ...parsedData]);
|
||||||
// setTotalCount((prev) => prev + parsedData.length);
|
setTotalCount((prev) => prev + parsedData.length);
|
||||||
}
|
}
|
||||||
setHasMore(parsedData.length === pageSize);
|
setHasMore(parsedData.length === pageSize);
|
||||||
} else {
|
} else {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -299,14 +280,14 @@ export function ProcessInnovationPage() {
|
||||||
console.error("Error parsing project data:", parseError);
|
console.error("Error parsing project data:", parseError);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +295,7 @@ export function ProcessInnovationPage() {
|
||||||
toast.error(response.message || "خطا در دریافت اطلاعات پروژهها");
|
toast.error(response.message || "خطا در دریافت اطلاعات پروژهها");
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +304,7 @@ export function ProcessInnovationPage() {
|
||||||
toast.error("خطا در دریافت اطلاعات پروژهها");
|
toast.error("خطا در دریافت اطلاعات پروژهها");
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
// setTotalCount(0);
|
setTotalCount(0);
|
||||||
}
|
}
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -334,33 +315,19 @@ export function ProcessInnovationPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasMore && !loading) {
|
if (!loadingMore && hasMore && !loading) {
|
||||||
setCurrentPage((prev) => prev + 1);
|
setCurrentPage((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [hasMore, loading]);
|
}, [loadingMore, hasMore, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (date: CalendarDate) => {
|
fetchProjects(true);
|
||||||
if (date) setDate(date);
|
fetchTotalCount();
|
||||||
};
|
}, [sortConfig]);
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date?.start && date?.end) {
|
fetchStats();
|
||||||
fetchProjects(true);
|
}, [selectedProjects]);
|
||||||
fetchTotalCount();
|
|
||||||
}
|
|
||||||
}, [sortConfig, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (date?.start && date?.end) fetchStats();
|
|
||||||
}, [selectedProjects, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
|
|
@ -372,12 +339,12 @@ export function ProcessInnovationPage() {
|
||||||
const scrollContainer = document.querySelector(".overflow-auto");
|
const scrollContainer = document.querySelector(".overflow-auto");
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollContainer || !hasMore) return;
|
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
if (scrollPercentage == 1) {
|
if (scrollPercentage >= 0.9) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -410,11 +377,7 @@ export function ProcessInnovationPage() {
|
||||||
const response = await apiService.select({
|
const response = await apiService.select({
|
||||||
ProcessName: "project",
|
ProcessName: "project",
|
||||||
OutputFields: ["count(project_no)"],
|
OutputFields: ["count(project_no)"],
|
||||||
Conditions: [
|
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]],
|
||||||
["type_of_innovation", "=", "نوآوری در فرآیند", "and"],
|
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -448,8 +411,6 @@ export function ProcessInnovationPage() {
|
||||||
selectedProjects.size > 0
|
selectedProjects.size > 0
|
||||||
? Array.from(selectedProjects).join(" , ")
|
? Array.from(selectedProjects).join(" , ")
|
||||||
: "",
|
: "",
|
||||||
start_date: date?.start || null,
|
|
||||||
end_date: date?.end || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -480,13 +441,10 @@ export function ProcessInnovationPage() {
|
||||||
totalProjects: parseNum(stats?.count_innovation_process_projects),
|
totalProjects: parseNum(stats?.count_innovation_process_projects),
|
||||||
averageScore: parseFloat(data[0].average_project_score),
|
averageScore: parseFloat(data[0].average_project_score),
|
||||||
productionStopsPreventionSum: parseNum(stats?.sum_stopping_production),
|
productionStopsPreventionSum: parseNum(stats?.sum_stopping_production),
|
||||||
reductionCostOprationSum: parseNum(stats?.sum_operating_cost_reduction),
|
|
||||||
bottleneckRemovalCount: parseNum(stats?.count_throat_removal),
|
bottleneckRemovalCount: parseNum(stats?.count_throat_removal),
|
||||||
currencyReductionSum: parseNum(stats?.sum_reduction_value_currency),
|
currencyReductionSum: parseNum(stats?.sum_reduction_value_currency),
|
||||||
frequentFailuresReductionSum: parseNum(stats?.sum_reducing_breakdowns),
|
frequentFailuresReductionSum: parseNum(stats?.sum_reducing_breakdowns),
|
||||||
percentProductionStops: stats?.percent_sum_stopping_production,
|
percentProductionStops: stats?.percent_sum_stopping_production,
|
||||||
percentOperatingCostBeforeInnovation:
|
|
||||||
stats?.percent_operating_cost_before_innovation,
|
|
||||||
percentBottleneckRemoval: stats?.percent_throat_removal,
|
percentBottleneckRemoval: stats?.percent_throat_removal,
|
||||||
percentCurrencyReduction: stats?.percent_reduction_value_currency,
|
percentCurrencyReduction: stats?.percent_reduction_value_currency,
|
||||||
percentFailuresReduction: stats?.percent_reducing_breakdowns,
|
percentFailuresReduction: stats?.percent_reducing_breakdowns,
|
||||||
|
|
@ -509,10 +467,6 @@ export function ProcessInnovationPage() {
|
||||||
...prev.currencyreduction,
|
...prev.currencyreduction,
|
||||||
value: formatNumber(normalized.currencyReductionSum),
|
value: formatNumber(normalized.currencyReductionSum),
|
||||||
},
|
},
|
||||||
decreaseCurrencyOperation: {
|
|
||||||
...prev.decreaseCurrencyOperation,
|
|
||||||
value: formatNumber(normalized.reductionCostOprationSum),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
setStats(normalized);
|
setStats(normalized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -565,7 +519,7 @@ export function ProcessInnovationPage() {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedProjects.has(item.project_id)}
|
checked={selectedProjects.has(item.project_id)}
|
||||||
onCheckedChange={() => handleSelectProject(item.project_id)}
|
onCheckedChange={() => handleSelectProject(item.project_id)}
|
||||||
className="data-[state=checked]:bg-pr-green data-[state=checked]:border-pr-green"
|
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "details":
|
case "details":
|
||||||
|
|
@ -574,35 +528,31 @@ export function ProcessInnovationPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleProjectDetails(item)}
|
onClick={() => handleProjectDetails(item)}
|
||||||
className="text-pr-green underline-offset-4 underline font-normal p-2 h-auto"
|
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto"
|
||||||
>
|
>
|
||||||
جزئیات بیشتر
|
جزئیات بیشتر
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case "amount_currency_reduction":
|
case "amount_currency_reduction":
|
||||||
return (
|
return (
|
||||||
<span className="font-medium text-pr-green">
|
<span className="font-medium text-emerald-400">
|
||||||
{formatCurrency(String(value))}
|
{formatCurrency(String(value))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "project_no":
|
case "project_no":
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="font-normal text-sm">
|
<Badge variant="outline" className="font-mono">
|
||||||
{String(value)}
|
{String(value)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return <span className="font-medium text-white">{String(value)}</span>;
|
||||||
<span className="font-normal text-sm text-white">
|
|
||||||
{String(value)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
case "project_status":
|
case "project_status":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Badge
|
<Badge
|
||||||
variant={statusColor(value as projectStatus)}
|
variant={statusColor(value)}
|
||||||
className="font-normal text-base border-2 p-0 block w-2 h-2 rounded-full"
|
className="font-medium border-2 p-0 block w-2 h-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
}}
|
}}
|
||||||
|
|
@ -612,10 +562,7 @@ export function ProcessInnovationPage() {
|
||||||
);
|
);
|
||||||
case "project_rating":
|
case "project_rating":
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant="outline" className="text-lg text-center border-none">
|
||||||
variant="outline"
|
|
||||||
className="text-base font-semibold text-center border-none"
|
|
||||||
>
|
|
||||||
{formatNumber(String(value))}
|
{formatNumber(String(value))}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
@ -634,201 +581,122 @@ export function ProcessInnovationPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout title="نوآوری در فرآیند">
|
<DashboardLayout title="نوآوری در فرآیند">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-6">
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-6 w-full">
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="h-full">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{loading || statsLoading ? (
|
{loading || statsLoading
|
||||||
// Skeleton cards
|
? // Loading skeleton for stats cards - matching new design
|
||||||
<div className="flex flex-wrap justify-between gap-3">
|
Array.from({ length: 4 }).map((_, index) => (
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
<Card
|
||||||
<BaseCard
|
|
||||||
key={`skeleton-${index}`}
|
key={`skeleton-${index}`}
|
||||||
className="rounded-2xl overflow-hidden w-full sm:w-[48%] md:w-[30%]"
|
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col justify-between gap-2">
|
<CardContent className="p-2">
|
||||||
<div className="flex justify-between items-center border-b-2 mx-4 border-gray-500/20">
|
<div className="flex flex-col justify-between gap-2">
|
||||||
<div
|
<div className="flex justify-between items-center border-b-2 mx-4 border-gray-500/20">
|
||||||
className="h-6 bg-gray-600 rounded animate-pulse"
|
<div
|
||||||
style={{ width: "60%" }}
|
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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center flex-col p-1">
|
</CardContent>
|
||||||
<div
|
</Card>
|
||||||
className="h-8 bg-gray-600 rounded mb-1 animate-pulse"
|
))
|
||||||
style={{ width: "40%" }}
|
: Object.entries(stateCard).map(([key, card]) => (
|
||||||
/>
|
<Card
|
||||||
<div
|
key={card.id}
|
||||||
className="h-4 bg-gray-600 rounded animate-pulse"
|
className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50"
|
||||||
style={{ width: "80%" }}
|
>
|
||||||
/>
|
<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>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</BaseCard>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Process Impacts Chart */}
|
{/* Process Impacts Chart */}
|
||||||
{/* نمودار با الگوریتم Nice Numbers:
|
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl w-full overflow-hidden">
|
||||||
مثلاً اگر دادهها [10, 35, 63, 18] باشند:
|
<CardContent>
|
||||||
- حداکثر: 63، با حاشیه 5% = 66.15
|
<CustomBarChart
|
||||||
- Nice Max: 75 (گرد و خوانا)
|
title="تاثیرات فرآیندی به صورت درصد مقایسه ای"
|
||||||
- Ticks: [0, 20, 40, 60, 75]
|
loading={statsLoading}
|
||||||
این باعث میشود نمودار زیباتر و خواناتر باشد */}
|
data={[
|
||||||
<BaseCard className="rounded-xl w-full overflow-hidden">
|
{
|
||||||
<CustomBarChart
|
label: "کاهش توقفات تولید",
|
||||||
title="تاثیرات فرآیندی به صورت درصد مقایسه ای"
|
value: stats.percentProductionStops || 0,
|
||||||
loading={statsLoading}
|
color: "bg-emerald-400",
|
||||||
data={[
|
labelColor: "text-white",
|
||||||
{
|
},
|
||||||
label: "توقفات تولید",
|
{
|
||||||
value: Number(stats.percentProductionStops) || 0,
|
label: "رفع گلوگاه تولید",
|
||||||
labelColor: "text-white",
|
value: stats.percentBottleneckRemoval || 0,
|
||||||
},
|
color: "bg-emerald-400",
|
||||||
{
|
labelColor: "text-white",
|
||||||
label: "رفع گلوگاه تولید",
|
},
|
||||||
value: Number(stats.percentBottleneckRemoval) || 0,
|
{
|
||||||
labelColor: "text-white",
|
label: "کاهش ارز بری",
|
||||||
},
|
value: stats.percentCurrencyReduction || 0,
|
||||||
{
|
color: "bg-emerald-400",
|
||||||
label: "ارز بری",
|
labelColor: "text-white",
|
||||||
value: Number(stats.percentCurrencyReduction) || 0,
|
},
|
||||||
labelColor: "text-white",
|
{
|
||||||
},
|
label: "کاهش خرابی پر تکرار",
|
||||||
{
|
value: stats.percentFailuresReduction || 0,
|
||||||
label: "خرابی پر تکرار",
|
color: "bg-emerald-400",
|
||||||
value: Number(stats.percentFailuresReduction) || 0,
|
labelColor: "text-white",
|
||||||
labelColor: "text-white",
|
},
|
||||||
},
|
]}
|
||||||
{
|
barHeight="h-5"
|
||||||
label: "هزینه های عملیاتی",
|
showAxisLabels={true}
|
||||||
value:
|
/>
|
||||||
Number(stats.percentOperatingCostBeforeInnovation) || 0,
|
</CardContent>
|
||||||
labelColor: "text-white",
|
</Card>
|
||||||
},
|
|
||||||
]}
|
|
||||||
barHeight="h-5"
|
|
||||||
showAxisLabels={true}
|
|
||||||
/>
|
|
||||||
</BaseCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
|
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(90vh-420px)]">
|
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(90vh-400px)]">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-[#3F415A]">
|
<TableRow className="bg-[#3F415A]">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|
@ -839,7 +707,14 @@ export function ProcessInnovationPage() {
|
||||||
>
|
>
|
||||||
{column.key === "select" ? (
|
{column.key === "select" ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<span></span>
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
selectedProjects.size === projects.length &&
|
||||||
|
projects.length > 0
|
||||||
|
}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : column.sortable ? (
|
) : column.sortable ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -875,7 +750,7 @@ export function ProcessInnovationPage() {
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className="text-right whitespace-nowrap border-pr-green py-1 px-2"
|
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
|
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
|
||||||
|
|
@ -908,7 +783,7 @@ export function ProcessInnovationPage() {
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className={`text-right whitespace-nowrap border-pr-green py-1 px-2 ${column.key === "select" ? "flex justify-center items-center" : ""}`}
|
className={`text-right whitespace-nowrap border-emerald-500/20 py-1 px-2 ${column.key === "select" ? "flex justify-center items-center" : ""}`}
|
||||||
>
|
>
|
||||||
{renderCellContent(project, column)}
|
{renderCellContent(project, column)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -925,7 +800,7 @@ export function ProcessInnovationPage() {
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className="w-4 h-4 animate-spin text-pr-green" />
|
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
|
||||||
<span className="font-persian text-gray-300 text-xs"></span>
|
<span className="font-persian text-gray-300 text-xs"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -935,7 +810,7 @@ export function ProcessInnovationPage() {
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
||||||
<div className="p-2 px-4 bg-[#3F415A]">
|
<div className="p-2 px-4 bg-gray-700/50">
|
||||||
<div className="flex gap-4 text-sm text-gray-300 font-persian justify-between sm:flex-col xl:flex-row">
|
<div className="flex gap-4 text-sm text-gray-300 font-persian justify-between sm:flex-col xl:flex-row">
|
||||||
<div className="text-center gap-2 items-center xl:w-1/3 pr-36 sm:w-full">
|
<div className="text-center gap-2 items-center xl:w-1/3 pr-36 sm:w-full">
|
||||||
<div className="text-base text-gray-401 mb-1">
|
<div className="text-base text-gray-401 mb-1">
|
||||||
|
|
@ -966,17 +841,15 @@ export function ProcessInnovationPage() {
|
||||||
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
<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">
|
<DialogContent className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian font-semibold text-sm text-right">
|
<DialogTitle className="text-white mr-4 border-b-2 border-gray-600 pb-4 font-persian text-right">
|
||||||
شرح پروژه
|
شرح پروژه
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 flex justify-between text-right p-6">
|
<div className="space-y-4 flex justify-between text-right px-6">
|
||||||
{/* Project Description */}
|
{/* Project Description */}
|
||||||
<div className="flex-[4] border-l-2 border-gray-600">
|
<div className="flex-[4] border-l-2 border-gray-600">
|
||||||
<h2 className="font-bold text-base">
|
<h2 className="font-bold">{selectedProjectDetails?.title}</h2>
|
||||||
{selectedProjectDetails?.title}
|
<p className="text-gray-300 font-persian px-2 mt-2">
|
||||||
</h2>
|
|
||||||
<p className="text-white font-normal text-base font-persian px-2 mt-2">
|
|
||||||
{selectedProjectDetails?.project_description || "-"}
|
{selectedProjectDetails?.project_description || "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -986,11 +859,11 @@ export function ProcessInnovationPage() {
|
||||||
<div className="font-bold text-right ">جزئیات پروژه</div>
|
<div className="font-bold text-right ">جزئیات پروژه</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
|
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
|
||||||
<Building2 className="h-4 text-pr-green text-sm font-light" />
|
<Building2 className="h-4 text-green-500" />
|
||||||
زمان شروع:
|
زمان شروع:
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-white font-normal text-base font-persian">
|
<span className="text-white font-bold font-persian">
|
||||||
{selectedProjectDetails?.start_date
|
{selectedProjectDetails?.start_date
|
||||||
? moment(
|
? moment(
|
||||||
selectedProjectDetails?.start_date,
|
selectedProjectDetails?.start_date,
|
||||||
|
|
@ -1001,11 +874,11 @@ export function ProcessInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
|
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
|
||||||
<PickaxeIcon className="h-4 text-pr-green text-sm font-light" />
|
<PickaxeIcon className="h-4 text-green-500" />
|
||||||
زمان پایان:
|
زمان پایان:
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-white font-normal text-base font-persian">
|
<span className="text-white font-bold font-persian">
|
||||||
{selectedProjectDetails?.done_date
|
{selectedProjectDetails?.done_date
|
||||||
? moment(
|
? moment(
|
||||||
selectedProjectDetails?.done_date,
|
selectedProjectDetails?.done_date,
|
||||||
|
|
@ -1016,29 +889,27 @@ export function ProcessInnovationPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
|
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
|
||||||
<UsersIcon className="h-4 text-pr-green text-sm font-light" />
|
<UsersIcon className="h-4 text-green-500" />
|
||||||
هزینه برآورد شده:
|
هزینه برآورد شده:
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-white font-normal text-base font-persian">
|
<span className="text-white font-bold font-persian">
|
||||||
{selectedProjectDetails?.approved_budget
|
{formatNumber(
|
||||||
? formatNumber(
|
Number(
|
||||||
Number(
|
selectedProjectDetails?.approved_budget.replaceAll(
|
||||||
selectedProjectDetails.approved_budget.replaceAll(
|
",",
|
||||||
",",
|
""
|
||||||
""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: "-"}
|
)
|
||||||
|
) || "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-light text-sm text-white font-persian mb-2 flex items-center gap-1">
|
<h4 className="font-medium text-gray-300 font-persian mb-2 flex items-center gap-1">
|
||||||
<UserIcon className="h-4 text-pr-green text-sm font-light" />
|
<UserIcon className="h-4 text-green-500" />
|
||||||
نفر مرتبط:
|
نفر مرتبط:
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-white font-normal text-base font-persian">
|
<span className="text-white font-bold font-persian">
|
||||||
{selectedProjectDetails?.observer || "-"}
|
{selectedProjectDetails?.observer || "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,28 +1,18 @@
|
||||||
import { saveAs } from "file-saver";
|
|
||||||
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import XLSX from "xlsx-js-style";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import {
|
import { formatCurrency } from "~/lib/utils";
|
||||||
EventBus,
|
|
||||||
formatCurrency,
|
|
||||||
formatNumber,
|
|
||||||
handleDataValue,
|
|
||||||
} from "~/lib/utils";
|
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
import { DashboardLayout } from "../layout";
|
import { DashboardLayout } from "../layout";
|
||||||
|
|
||||||
interface ProjectData {
|
interface ProjectData {
|
||||||
|
|
@ -62,51 +52,51 @@ type ColumnDef = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef[] = [
|
const columns: ColumnDef[] = [
|
||||||
{ key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
|
{ key: "title", label: "عنوان پروژه", sortable: true, width: "200px" },
|
||||||
{
|
{
|
||||||
key: "importance_project",
|
key: "importance_project",
|
||||||
label: "میزان اهمیت",
|
label: "میزان اهمیت",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "160px",
|
width: "150px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "strategic_theme",
|
key: "strategic_theme",
|
||||||
label: "مضمون راهبردی",
|
label: "مضمون راهبردی",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "200px",
|
width: "160px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "value_technology_and_innovation",
|
key: "value_technology_and_innovation",
|
||||||
label: "ارزش فناوری و نوآوری",
|
label: "ارزش فناوری و نوآوری",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "220px",
|
width: "200px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "type_of_innovation",
|
key: "type_of_innovation",
|
||||||
label: "انواع نوآوری",
|
label: "انواع نوآوری",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "160px",
|
width: "140px",
|
||||||
},
|
},
|
||||||
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "140px" },
|
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" },
|
||||||
{
|
{
|
||||||
key: "person_executing",
|
key: "person_executing",
|
||||||
label: "مسئول اجرا",
|
label: "مسئول اجرا",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "180px",
|
width: "140px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "excellent_observer",
|
key: "excellent_observer",
|
||||||
label: "ناطر عالی",
|
label: "ناطر عالی",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "180px",
|
width: "140px",
|
||||||
},
|
},
|
||||||
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "180px" },
|
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
|
||||||
{ key: "moderator", label: "مجری", sortable: true, width: "180px" },
|
{ key: "moderator", label: "مجری", sortable: true, width: "140px" },
|
||||||
{
|
{
|
||||||
key: "executive_phase",
|
key: "executive_phase",
|
||||||
label: "فاز اجرایی",
|
label: "فاز اجرایی",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: "160px",
|
width: "140px",
|
||||||
},
|
},
|
||||||
{ key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
|
{ key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
|
||||||
{
|
{
|
||||||
|
|
@ -175,14 +165,6 @@ export function ProjectManagementPage() {
|
||||||
});
|
});
|
||||||
const observerRef = useRef<HTMLDivElement>(null);
|
const observerRef = useRef<HTMLDivElement>(null);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
// const [date, setDate] = useState<CalendarDate>({
|
|
||||||
// start: `${jy}/01/01`,
|
|
||||||
// end: `${jy}/12/30`,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
|
|
||||||
const fetchProjects = async (reset = false) => {
|
const fetchProjects = async (reset = false) => {
|
||||||
// Prevent concurrent API calls
|
// Prevent concurrent API calls
|
||||||
|
|
@ -214,10 +196,7 @@ export function ProjectManagementPage() {
|
||||||
OutputFields: outputFields,
|
OutputFields: outputFields,
|
||||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||||
Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
|
Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
|
||||||
Conditions: [
|
Conditions: [],
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -282,29 +261,16 @@ export function ProjectManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (date: CalendarDate) => {
|
|
||||||
if (date) setDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
|
if (!loadingMore && hasMore && !loading) {
|
||||||
setCurrentPage((prev) => prev + 1);
|
setCurrentPage((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [hasMore, loading, loadingMore]);
|
}, [loadingMore, hasMore, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date.end && date.start) {
|
fetchProjects(true);
|
||||||
fetchProjects(true);
|
fetchTotalCount();
|
||||||
fetchTotalCount();
|
}, [sortConfig]);
|
||||||
}
|
|
||||||
}, [sortConfig, date]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
|
|
@ -312,44 +278,30 @@ export function ProjectManagementPage() {
|
||||||
}
|
}
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
|
|
||||||
// Infinite scroll observer with debouncing
|
// Infinite scroll observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = document.querySelector(".overflow-auto");
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current)
|
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||||
return;
|
|
||||||
|
|
||||||
// Clear previous timeout
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
if (scrollTimeoutRef.current) {
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
|
// Trigger load more when scrolled to 90% of the container
|
||||||
|
if (scrollPercentage >= 0.9) {
|
||||||
|
loadMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (scrollContainer) {
|
||||||
scrollContainer.addEventListener("scroll", handleScroll, {
|
scrollContainer.addEventListener("scroll", handleScroll);
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.removeEventListener("scroll", handleScroll);
|
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||||
}
|
}
|
||||||
if (scrollTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [loadMore, hasMore, loadingMore]);
|
}, [loadMore, hasMore, loadingMore]);
|
||||||
|
|
||||||
|
|
@ -370,10 +322,7 @@ export function ProjectManagementPage() {
|
||||||
const response = await apiService.select({
|
const response = await apiService.select({
|
||||||
ProcessName: "project",
|
ProcessName: "project",
|
||||||
OutputFields: ["count(project_no)"],
|
OutputFields: ["count(project_no)"],
|
||||||
Conditions: [
|
Conditions: [],
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.state === 0) {
|
if (response.state === 0) {
|
||||||
|
|
@ -394,16 +343,21 @@ export function ProjectManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
// fetchingRef.current = false; // Reset fetching state on refresh
|
fetchingRef.current = false; // Reset fetching state on refresh
|
||||||
// setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
// setProjects([]);
|
setProjects([]);
|
||||||
// setHasMore(true);
|
setHasMore(true);
|
||||||
// fetchProjects(true);
|
fetchProjects(true);
|
||||||
// fetchTotalCount();
|
fetchTotalCount();
|
||||||
// };
|
};
|
||||||
|
|
||||||
// ...existing code...
|
const formatNumber = (value: string | number) => {
|
||||||
|
if (value === undefined || value === null || value === "") return "0";
|
||||||
|
const numericValue = typeof value === "string" ? Number(value) : value;
|
||||||
|
if (Number.isNaN(numericValue)) return "0";
|
||||||
|
return new Intl.NumberFormat("fa-IR").format(numericValue as number);
|
||||||
|
};
|
||||||
|
|
||||||
const toPersianDigits = (input: string | number): string => {
|
const toPersianDigits = (input: string | number): string => {
|
||||||
const str = String(input);
|
const str = String(input);
|
||||||
|
|
@ -545,160 +499,6 @@ 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 renderCellContent = (item: ProjectData, column: ColumnDef) => {
|
||||||
const apiField = column.apiField ?? column.key;
|
const apiField = column.apiField ?? column.key;
|
||||||
const value = (item as any)[apiField];
|
const value = (item as any)[apiField];
|
||||||
|
|
@ -713,7 +513,7 @@ export function ProjectManagementPage() {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="flex justify-end gap-1 items-center"
|
className="font-medium flex justify-end gap-1 items-center"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
>
|
>
|
||||||
<span>روز</span> {toPersianDigits(days)}
|
<span>روز</span> {toPersianDigits(days)}
|
||||||
|
|
@ -724,65 +524,55 @@ export function ProjectManagementPage() {
|
||||||
case "value_technology_and_innovation":
|
case "value_technology_and_innovation":
|
||||||
case "type_of_innovation":
|
case "type_of_innovation":
|
||||||
case "innovation":
|
case "innovation":
|
||||||
case "executive_phase": {
|
|
||||||
const color = getCategoryColor(column.key, value);
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
|
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
|
||||||
<span className="text-gray-300">
|
<span className="text-gray-300">{String(value) || "-"}</span>
|
||||||
{!!value ? String(value) : "-"}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: `${column.key === "strategic_theme" ? "#6D53FB" : column.key === "value_technology_and_innovation" ? "#A757FF" : column.key === "type_of_innovation" ? "#E884CE" : "#C3BF8B"}`,
|
||||||
display: !value ? "none" : "block",
|
|
||||||
}}
|
}}
|
||||||
className="inline-block w-2 h-2 rounded-full"
|
className="inline-block w-2 h-2 rounded-full bg-emerald-400"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
case "approved_budget":
|
case "approved_budget":
|
||||||
case "budget_spent":
|
case "budget_spent":
|
||||||
return (
|
return (
|
||||||
<span className=" text-emerald-400 font-normal">
|
<span className="font-medium text-emerald-400">
|
||||||
{formatCurrency(String(value))}
|
{formatCurrency(String(value))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "deviation_from_program":
|
case "deviation_from_program":
|
||||||
case "cost_deviation":
|
case "cost_deviation":
|
||||||
return (
|
return (
|
||||||
<span className="text-sm font-normal">
|
<span className="text-gray-300">{formatNumber(value as any)}</span>
|
||||||
{formatNumber(value as any)}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
case "start_date":
|
case "start_date":
|
||||||
case "end_date":
|
case "end_date":
|
||||||
case "done_date":
|
case "done_date":
|
||||||
return (
|
return (
|
||||||
<span className=" text-sm font-normal">
|
<span className="text-gray-300">{formatDate(String(value))}</span>
|
||||||
{formatDate(String(value))}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
case "project_no":
|
case "project_no":
|
||||||
return (
|
return (
|
||||||
<Badge variant="teal" className="border-emerald-500/50">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-emerald-400 border-emerald-500/50"
|
||||||
|
>
|
||||||
{String(value)}
|
{String(value)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return <span className="font-medium text-white">{String(value)}</span>;
|
||||||
<span className="text-sm font-normal text-white">
|
|
||||||
{String(value)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
case "importance_project":
|
case "importance_project":
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 text-sm rounded-lg"
|
className="font-medium border-2"
|
||||||
style={{
|
style={{
|
||||||
color: getImportanceColor(String(value)),
|
color: getImportanceColor(String(value)),
|
||||||
borderColor: getImportanceColor(String(value)),
|
borderColor: getImportanceColor(String(value)),
|
||||||
|
backgroundColor: `${getImportanceColor(String(value))}20`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{String(value)}
|
{String(value)}
|
||||||
|
|
@ -790,356 +580,108 @@ export function ProjectManagementPage() {
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<span className="font-light text-sm">
|
<span className="text-gray-300">
|
||||||
{(value && String(value)) || "-"}
|
{(value && String(value)) || "-"}
|
||||||
</span>
|
</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 (
|
return (
|
||||||
<DashboardLayout title="مدیریت پروژهها">
|
<DashboardLayout title="مدیریت پروژهها">
|
||||||
<div className="space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* <div className="flex justify-end w-full mb-0 pl-2">
|
|
||||||
<Button
|
|
||||||
className="flex w-max justify-center rounded-xl mb-4 border-gray-500/20 border-2 cursor-pointer transition-all hover:bg-[#3F415A]/50 bg-[#3F415A] py-3 text-center items-center gap-3 "
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={exportToExcel}
|
|
||||||
>
|
|
||||||
<FileChartColumnIncreasing />
|
|
||||||
دانلود فایل اکسل
|
|
||||||
</Button>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
|
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
|
||||||
{/* <div onClick={exportToExcel}>DownloadExcle</div> */}
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(100vh-200px)]">
|
||||||
ref={scrollContainerRef}
|
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
|
||||||
className="relative overflow-auto custom-scrollbar max-h-[calc(100vh-120px)]"
|
<TableRow className="bg-[#3F415A]">
|
||||||
>
|
{columns.map((column) => (
|
||||||
<Table className="table-fixed">
|
<TableHead
|
||||||
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
|
key={column.key}
|
||||||
<TableRow className="bg-[#3F415A]">
|
className="text-right font-persian whitespace-nowrap text-gray-200 font-medium bg-[#3F415A] sticky top-0 z-20"
|
||||||
{columns.map((column) => (
|
style={{ width: column.width }}
|
||||||
<TableHead
|
>
|
||||||
key={column.key}
|
{column.sortable ? (
|
||||||
className={` text-right font-persian whitespace-nowrap text-white font-semibold bg-[#3F415A] sticky top-0 z-20`}
|
<button
|
||||||
style={{ width: column.width }}
|
onClick={() => handleSort(column.key)}
|
||||||
>
|
className="flex items-center gap-2"
|
||||||
{column.sortable ? (
|
>
|
||||||
<button
|
<span>{column.label}</span>
|
||||||
onClick={() => handleSort(column.key)}
|
{sortConfig.field === column.key ? (
|
||||||
className="flex items-center gap-2"
|
sortConfig.direction === "asc" ? (
|
||||||
>
|
<ChevronUp className="w-4 h-4" />
|
||||||
<span>{column.label}</span>
|
|
||||||
{sortConfig.field === column.key ? (
|
|
||||||
sortConfig.direction === "asc" ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
)}
|
)
|
||||||
</button>
|
) : (
|
||||||
) : (
|
<div className="w-4 h-4" />
|
||||||
column.label
|
)}
|
||||||
)}
|
</button>
|
||||||
</TableHead>
|
) : (
|
||||||
))}
|
column.label
|
||||||
</TableRow>
|
)}
|
||||||
</TableHeader>
|
</TableHead>
|
||||||
|
))}
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{loading ? (
|
</TableHeader>
|
||||||
// Skeleton loading rows (compact)
|
<TableBody>
|
||||||
Array.from({ length: 20 }).map((_, index) => (
|
{loading ? (
|
||||||
<TableRow
|
// Skeleton loading rows (compact)
|
||||||
key={`skeleton-${index}`}
|
Array.from({ length: 20 }).map((_, index) => (
|
||||||
className="text-sm leading-tight h-8"
|
<TableRow
|
||||||
>
|
key={`skeleton-${index}`}
|
||||||
{columns.map((column) => (
|
className="text-sm leading-tight h-8"
|
||||||
<TableCell
|
>
|
||||||
key={column.key}
|
{columns.map((column) => (
|
||||||
className="text-right border-emerald-500/20 py-1 px-2 break-words"
|
<TableCell
|
||||||
>
|
key={column.key}
|
||||||
<div className="flex items-center gap-2">
|
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||||
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
|
>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="h-2.5 bg-gray-600 rounded animate-pulse"
|
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
|
||||||
style={{
|
<div
|
||||||
width: `${Math.random() * 60 + 40}%`,
|
className="h-2.5 bg-gray-600 rounded animate-pulse"
|
||||||
}}
|
style={{ width: `${Math.random() * 60 + 40}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
) : (
|
))
|
||||||
projects.map((project, index) => (
|
) : projects.length === 0 ? (
|
||||||
<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>
|
<TableRow>
|
||||||
{columns.map((column, colIndex) => {
|
<TableCell
|
||||||
// First column: show total projects text similar to API count
|
colSpan={columns.length}
|
||||||
if (colIndex === 0) {
|
className="text-center py-8"
|
||||||
return (
|
>
|
||||||
<TableCell
|
<span className="text-gray-400 font-persian">
|
||||||
key={column.key}
|
هیچ پروژهای یافت نشد
|
||||||
className="p-3 text-sm text-white font-semibold font-persian"
|
</span>
|
||||||
>
|
</TableCell>
|
||||||
کل پروژهها: {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>
|
</TableRow>
|
||||||
</TableFooter>
|
) : (
|
||||||
</Table>
|
projects.map((project, index) => (
|
||||||
</div>
|
<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>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Infinite scroll trigger */}
|
{/* Infinite scroll trigger */}
|
||||||
|
|
@ -1147,13 +689,20 @@ export function ProjectManagementPage() {
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className="w-4 h-3 animate-spin text-emerald-400" />
|
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
|
||||||
<span className="font-persian text-gray-300 text-xs"></span>
|
<span className="font-persian text-gray-300 text-xs"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,31 @@
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
|
Building2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderKanban,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
House,
|
Globe,
|
||||||
LightbulbIcon,
|
LayoutDashboard,
|
||||||
ListTodo,
|
Leaf,
|
||||||
|
Lightbulb,
|
||||||
LogOut,
|
LogOut,
|
||||||
Radar,
|
MonitorSmartphone,
|
||||||
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
Star,
|
Star,
|
||||||
Workflow,
|
Workflow,
|
||||||
DiscAlbum,
|
|
||||||
LucideLightbulb
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { useAuth } from "~/contexts/auth-context";
|
import { useAuth } from "~/contexts/auth-context";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface TitleInfo {
|
|
||||||
title: string;
|
|
||||||
icon?: React.ComponentType<{ className?: string }> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
onStrategicAlignmentClick?: () => void;
|
onStrategicAlignmentClick?: () => void;
|
||||||
onTitleChange?: (info: TitleInfo) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
|
|
@ -40,51 +38,52 @@ interface MenuItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "dashboard",
|
id: "dashboard",
|
||||||
label: "صفحه اصلی",
|
label: "صفحه اصلی",
|
||||||
icon: House,
|
icon: LayoutDashboard,
|
||||||
href: "/dashboard",
|
href: "/dashboard",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "project-management",
|
id: "project-management",
|
||||||
label: "مدیریت اجرای پروژهها",
|
label: "مدیریت اجرای پروژهها",
|
||||||
icon: ListTodo,
|
icon: FolderKanban,
|
||||||
href: "/dashboard/project-management",
|
href: "/dashboard/project-management",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "innovation-basket",
|
id: "innovation-basket",
|
||||||
label: "سبد فناوری و نوآوری",
|
label: "سبد فناوری و نوآوری",
|
||||||
icon: LightbulbIcon,
|
icon: Box,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "product-innovation",
|
id: "product-innovation",
|
||||||
label: "نوآوری در محصول",
|
label: "نوآوری در محصول",
|
||||||
icon: null,
|
icon: Package,
|
||||||
href: "/dashboard/innovation-basket/product-innovation",
|
href: "/dashboard/innovation-basket/product-innovation",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "process-innovation",
|
id: "process-innovation",
|
||||||
label: "نوآوری در فرآیند",
|
label: "نوآوری در فرآیند",
|
||||||
icon: null,
|
icon: Workflow,
|
||||||
href: "/dashboard/innovation-basket/process-innovation",
|
href: "/dashboard/innovation-basket/process-innovation",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "digital-innovation",
|
id: "digital-innovation",
|
||||||
label: "نوآوری دیجیتال",
|
label: "نوآوری دیجیتال",
|
||||||
icon: null,
|
icon: MonitorSmartphone,
|
||||||
href: "/dashboard/innovation-basket/digital-innovation",
|
href: "/dashboard/innovation-basket/digital-innovation",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "green-innovation",
|
id: "green-innovation",
|
||||||
label: "نوآوری سبز",
|
label: "نوآوری سبز",
|
||||||
icon: null,
|
icon: Leaf,
|
||||||
href: "/dashboard/innovation-basket/green-innovation",
|
href: "/dashboard/innovation-basket/green-innovation",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "internal-innovation",
|
id: "internal-innovation",
|
||||||
label: "نوآوری ساخت داخل",
|
label: "نوآوری ساخت داخل",
|
||||||
icon: null,
|
icon: Building2,
|
||||||
href: "/dashboard/innovation-basket/internal-innovation",
|
href: "/dashboard/innovation-basket/internal-innovation",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -92,16 +91,22 @@ const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: "ecosystem",
|
id: "ecosystem",
|
||||||
label: "زیست بوم فناوری و نوآوری",
|
label: "زیست بوم فناوری و نوآوری",
|
||||||
icon: Radar,
|
icon: Globe,
|
||||||
href: "/dashboard/ecosystem",
|
href: "/dashboard/ecosystem",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ideas",
|
id: "ideas",
|
||||||
label: "ایدههای فناوری و نوآوری",
|
label: "ایدههای فناوری و نوآوری",
|
||||||
icon: LucideLightbulb,
|
icon: Lightbulb,
|
||||||
href: "/dashboard/manage-ideas-tech",
|
href: "/dashboard/manage-ideas-tech",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "top-innovations",
|
||||||
|
label: "نوآور برتر",
|
||||||
|
icon: Star,
|
||||||
|
href: "/dashboard/top-innovations",
|
||||||
|
},
|
||||||
|
{
|
||||||
id: "strategic-alignment",
|
id: "strategic-alignment",
|
||||||
label: "میزان انطباق راهبردی",
|
label: "میزان انطباق راهبردی",
|
||||||
icon: null,
|
icon: null,
|
||||||
|
|
@ -110,12 +115,12 @@ const menuItems: MenuItem[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomMenuItems: MenuItem[] = [
|
const bottomMenuItems: MenuItem[] = [
|
||||||
// {
|
{
|
||||||
// id: "settings",
|
id: "settings",
|
||||||
// label: "تنظیمات",
|
label: "تنظیمات",
|
||||||
// icon: Settings,
|
icon: Settings,
|
||||||
// href: "/dashboard/settings",
|
href: "/dashboard/settings",
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
id: "logout",
|
id: "logout",
|
||||||
label: "خروج",
|
label: "خروج",
|
||||||
|
|
@ -129,7 +134,6 @@ export function Sidebar({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
className,
|
className,
|
||||||
onStrategicAlignmentClick,
|
onStrategicAlignmentClick,
|
||||||
onTitleChange,
|
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||||
|
|
@ -152,35 +156,6 @@ export function Sidebar({
|
||||||
});
|
});
|
||||||
|
|
||||||
setExpandedItems(newExpandedItems);
|
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();
|
autoExpandParents();
|
||||||
|
|
@ -230,12 +205,6 @@ export function Sidebar({
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
|
|
||||||
const handleClick = () => {
|
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") {
|
if (item.id === "strategic-alignment") {
|
||||||
onStrategicAlignmentClick?.();
|
onStrategicAlignmentClick?.();
|
||||||
} else if (item.id === "logout") {
|
} else if (item.id === "logout") {
|
||||||
|
|
@ -245,23 +214,30 @@ export function Sidebar({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (item.id === "strategic-alignment") {
|
if (item.id === "strategic-alignment") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center w-full px-2 rounded-none mt-4 transition-all duration-200 group"
|
"w-full text-right",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex justify-center rounded-xl border-gray-500/20 border-2 cursor-pointer transition-all hover:bg-[#3F415A]/50 bg-[#3F415A] py-2 text-center items-center gap-3 min-w-0 flex-1">
|
<div
|
||||||
<span className="font-persian text-sm font-medium truncate">
|
className={cn(
|
||||||
{item.label}
|
"flex items-center justify-center w-full px-2 rounded-lg mt-4 transition-all duration-200 group",
|
||||||
</span>
|
)}
|
||||||
</div>
|
>
|
||||||
</button>
|
<div className="flex justify-center rounded-xl border-gray-500/20 border-2 cursor-pointer transition-all hover:bg-[#3F415A]/50 bg-[#3F415A] py-2 text-center items-center gap-3 min-w-0 flex-1">
|
||||||
);
|
<span className="font-persian text-sm font-medium truncate">
|
||||||
}
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="relative">
|
<div key={item.id} className="relative">
|
||||||
|
|
@ -269,24 +245,22 @@ export function Sidebar({
|
||||||
<Link to={item.href} className="block">
|
<Link to={item.href} className="block">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between rounded-none w-full py-2 px-3 transition-all duration-200 group",
|
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
|
||||||
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
||||||
isActive
|
isActive
|
||||||
? " text-pr-green border-r-2 border-pr-green"
|
? " text-emerald-400 border-r-2 border-emerald-400"
|
||||||
: "text-gray-300 hover:text-pr-green",
|
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
|
||||||
isCollapsed && level === 0 && "justify-center px-2",
|
isCollapsed && level === 0 && "justify-center px-2",
|
||||||
item.id === "logout" && "hover:text-pr-red"
|
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
{ItemIcon && (
|
<ItemIcon
|
||||||
<ItemIcon
|
className={cn(
|
||||||
className={cn(
|
"w-5 h-5 flex-shrink-0",
|
||||||
"w-5 h-5 flex-shrink-0",
|
isActive ? "text-emerald-400" : "text-current"
|
||||||
isActive ? "text-pr-green" : "text-current"
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="font-persian text-sm font-medium truncate">
|
<span className="font-persian text-sm font-medium truncate">
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -297,7 +271,7 @@ export function Sidebar({
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<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">
|
<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">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -328,24 +302,22 @@ export function Sidebar({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between w-full py-2 px-3 rounded-none transition-all duration-200 group",
|
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
|
||||||
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
||||||
isActive
|
isActive
|
||||||
? " text-pr-green border-r-2 border-pr-green"
|
? " text-emerald-400 border-r-2 border-emerald-400"
|
||||||
: "text-gray-300 cursor-pointer hover:text-pr-green",
|
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
|
||||||
isCollapsed && level === 0 && "justify-center px-2",
|
isCollapsed && level === 0 && "justify-center px-2",
|
||||||
item.id === "logout" && "hover:text-pr-red"
|
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
{ItemIcon && (
|
<ItemIcon
|
||||||
<ItemIcon
|
className={cn(
|
||||||
className={cn(
|
"w-5 h-5 flex-shrink-0",
|
||||||
"w-5 h-5 flex-shrink-0",
|
isActive ? "text-emerald-400" : "text-current"
|
||||||
isActive ? "text-pr-green" : "text-current"
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="font-persian text-sm font-medium truncate">
|
<span className="font-persian text-sm font-medium truncate">
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -356,7 +328,7 @@ export function Sidebar({
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<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">
|
<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">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -371,7 +343,7 @@ export function Sidebar({
|
||||||
(child) =>
|
(child) =>
|
||||||
child.href && location.pathname === child.href
|
child.href && location.pathname === child.href
|
||||||
)
|
)
|
||||||
? "text-pr-green"
|
? "text-emerald-400"
|
||||||
: "text-current"
|
: "text-current"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -423,9 +395,9 @@ export function Sidebar({
|
||||||
/>
|
/>
|
||||||
<div className="font-persian">
|
<div className="font-persian">
|
||||||
<div className="text-sm font-semibold text-white">
|
<div className="text-sm font-semibold text-white">
|
||||||
داشبورد مدیریت فناوری و نوآوری
|
داشبورد اینوژن
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="text-xs text-gray-400">نسخه ۰.۱</div> */}
|
<div className="text-xs text-gray-400">نسخه ۰.۱</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -462,7 +434,7 @@ export function Sidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapse Toggle */}
|
{/* Collapse Toggle */}
|
||||||
{/* {onToggleCollapse && (
|
{onToggleCollapse && (
|
||||||
<div className="p-3 border-t border-gray-500/30">
|
<div className="p-3 border-t border-gray-500/30">
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
|
|
@ -479,7 +451,7 @@ export function Sidebar({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,32 @@
|
||||||
import { useEffect, useReducer, useRef, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Bar,
|
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
Bar,
|
||||||
Cell,
|
|
||||||
LabelList,
|
|
||||||
ResponsiveContainer,
|
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LabelList,
|
||||||
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Dialog, DialogContent, DialogHeader } from "~/components/ui/dialog";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { EventBus, formatNumber } from "~/lib/utils";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
import { formatNumber } from "~/lib/utils";
|
||||||
import { ChartContainer } from "../ui/chart";
|
import { ChartContainer } from "../ui/chart";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuButton,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
} from "../ui/dropdown-menu";
|
|
||||||
import { TruncatedText } from "../ui/truncatedText";
|
|
||||||
|
|
||||||
interface StrategicAlignmentData {
|
interface StrategicAlignmentData {
|
||||||
strategic_theme: string;
|
strategic_theme: string;
|
||||||
operational_fee_count: number;
|
operational_fee_sum: number;
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropDownConfig {
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedValue: string;
|
|
||||||
dropDownItems: Array<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: "OPEN" }
|
|
||||||
| { type: "CLOSE" }
|
|
||||||
| { type: "SETVALUE"; value: Array<string> }
|
|
||||||
| { type: "SELECT"; value: string };
|
|
||||||
|
|
||||||
// const DropDownItems = [
|
|
||||||
// {
|
|
||||||
// id: 0,
|
|
||||||
// key: "همه مضامین",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 1,
|
|
||||||
// key: "ارزش های هم افزایی نوآورانه",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 2,
|
|
||||||
// key: "ارزش های خودکفایی نوآوورانه",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 3,
|
|
||||||
// key: "ارزش های فناوری های نوین",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 4,
|
|
||||||
// key: "ارزش های توسعه منابع انسانی",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 5,
|
|
||||||
// key: "ارزش های نوآوری سبز",
|
|
||||||
// Value: "همه مضامین",
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
interface StrategicAlignmentPopupProps {
|
interface StrategicAlignmentPopupProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -88,10 +40,11 @@ const chartConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxHeight = 150;
|
const maxHeight = 150;
|
||||||
const barHeights = () => Math.floor(Math.random() * maxHeight);
|
const barHeights = () => Math.floor(Math.random() * maxHeight);
|
||||||
|
|
||||||
const ChartSkeleton = () => (
|
const ChartSkeleton = () => (
|
||||||
|
|
||||||
<div className="flex justify-center h-96 w-full p-4">
|
<div className="flex justify-center h-96 w-full p-4">
|
||||||
{/* Chart bars */}
|
{/* Chart bars */}
|
||||||
<div className=" w-full flex items-end gap-10">
|
<div className=" w-full flex items-end gap-10">
|
||||||
|
|
@ -104,7 +57,7 @@ const ChartSkeleton = () => (
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Left space for Y-axis label */}
|
{/* Left space for Y-axis label */}
|
||||||
<div className="flex flex-col justify-between mr-2">
|
<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" />
|
||||||
<Skeleton className="h-6 w-15 bg-gray-700 rounded" />
|
<Skeleton className="h-6 w-15 bg-gray-700 rounded" />
|
||||||
|
|
@ -120,14 +73,6 @@ export function StrategicAlignmentPopup({
|
||||||
}: StrategicAlignmentPopupProps) {
|
}: StrategicAlignmentPopupProps) {
|
||||||
const [data, setData] = useState<StrategicAlignmentData[]>([]);
|
const [data, setData] = useState<StrategicAlignmentData[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [state, dispatch] = useReducer(reducer, {
|
|
||||||
isOpen: false,
|
|
||||||
selectedValue: "همه مضامین",
|
|
||||||
dropDownItems: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -135,29 +80,16 @@ export function StrategicAlignmentPopup({
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (date: CalendarDate) => {
|
|
||||||
if (date) setDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiService.select({
|
const response = await apiService.select({
|
||||||
ProcessName: "project",
|
ProcessName: "project",
|
||||||
OutputFields: ["strategic_theme", "count(operational_fee)"],
|
OutputFields: [
|
||||||
GroupBy: ["strategic_theme"],
|
"strategic_theme",
|
||||||
Conditions: [
|
"sum(operational_fee) as operational_fee_sum",
|
||||||
["start_date", ">=", date?.start || null, "and"],
|
|
||||||
["start_date", "<=", date?.end || null],
|
|
||||||
],
|
],
|
||||||
|
GroupBy: ["strategic_theme"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData =
|
const responseData =
|
||||||
|
|
@ -165,12 +97,29 @@ export function StrategicAlignmentPopup({
|
||||||
? JSON.parse(response.data)
|
? JSON.parse(response.data)
|
||||||
: response.data;
|
: response.data;
|
||||||
|
|
||||||
setBarItems(responseData);
|
const processedData = responseData
|
||||||
const dropDownItems = responseData.map(
|
.map((item: any) => ({
|
||||||
(item: any) => item.strategic_theme
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
setDropDownValues(["همه مضامین", ...dropDownItems]);
|
const dataWithPercentage = processedData.map(
|
||||||
|
(item: StrategicAlignmentData) => ({
|
||||||
|
...item,
|
||||||
|
percentage:
|
||||||
|
total > 0
|
||||||
|
? Math.round((item.operational_fee_sum / total) * 100)
|
||||||
|
: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setData(dataWithPercentage || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching strategic alignment data:", error);
|
console.error("Error fetching strategic alignment data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -178,174 +127,19 @@ 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={dialogHandler}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full max-w-4xl bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] text-white border-none">
|
<DialogContent className="w-full max-w-4xl bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] text-white border-none">
|
||||||
<DialogHeader className="mb-10 w-full border-b-2 border-gray-500/20">
|
<DialogHeader className="border-b-3 mb-10 py-2 w-full pb-4 border-b-2 border-gray-500/20">
|
||||||
<div>
|
<DialogTitle className="ml-auto ">میزان انطباق راهبردی</DialogTitle>
|
||||||
<div className="flex">
|
|
||||||
<DropdownMenu
|
|
||||||
modal={true}
|
|
||||||
open={state.isOpen}
|
|
||||||
onOpenChange={toggleMenuHandler}
|
|
||||||
>
|
|
||||||
<DropdownMenuButton>{state.selectedValue}</DropdownMenuButton>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
ref={contentRef}
|
|
||||||
forceMount={true}
|
|
||||||
className="w-56"
|
|
||||||
>
|
|
||||||
{state.dropDownItems.map((item: string, key: number) => (
|
|
||||||
<div
|
|
||||||
onClick={() => selectItem(item)}
|
|
||||||
key={`${key}-${item}`}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem selected={state.selectedValue === item}>
|
|
||||||
{item}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ChartSkeleton />
|
<ChartSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<ChartContainer
|
<ChartContainer config={chartConfig} className="aspect-auto h-96 w-full">
|
||||||
config={chartConfig}
|
|
||||||
className="aspect-auto h-96 w-full"
|
|
||||||
>
|
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ left: 12, right: 12 }}
|
margin={{ left: 12, right: 12 }}
|
||||||
|
|
@ -354,65 +148,49 @@ export function StrategicAlignmentPopup({
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} stroke="#475569" />
|
<CartesianGrid vertical={false} stroke="#475569" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="strategic_theme"
|
dataKey="strategic_theme"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={10}
|
||||||
interval={0}
|
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
||||||
style={{ fill: "#94a3b8", fontSize: 14 }}
|
|
||||||
tick={(props) => {
|
|
||||||
const { x, y, payload } = props;
|
|
||||||
return (
|
|
||||||
<g transform={`translate(${x},${y})`}>
|
|
||||||
<foreignObject width={80} height={20} x={-45} y={0}>
|
|
||||||
<TruncatedText maxWords={2} text={payload.value} />
|
|
||||||
</foreignObject>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={20}
|
tickMargin={8}
|
||||||
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
||||||
tickFormatter={(value) =>
|
tickFormatter={(value) =>
|
||||||
`${formatNumber(Math.round(value))}`
|
`${formatNumber(Math.round(value))}%`
|
||||||
}
|
}
|
||||||
label={{
|
label={{
|
||||||
value: "تعداد برنامه ها",
|
value: "تعداد برنامه ها" ,
|
||||||
angle: -90,
|
angle: -90,
|
||||||
position: "insideLeft",
|
position: "insideLeft",
|
||||||
fill: "#94a3b8",
|
fill: "#94a3b8",
|
||||||
fontSize: 11,
|
fontSize: 14,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
dy: 0,
|
dy: 0,
|
||||||
style: { textAnchor: "middle" },
|
style: { textAnchor: "middle" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar dataKey="percentage" radius={[8, 8, 0, 0]}>
|
<Bar dataKey="percentage" radius={[8, 8, 0, 0]}>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell key={`cell-${index}`} fill={chartConfig.percentage.color} />
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={chartConfig.percentage.color}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<LabelList
|
<LabelList
|
||||||
dataKey="percentage"
|
dataKey="percentage"
|
||||||
position="top"
|
position="top"
|
||||||
offset={15}
|
|
||||||
style={{
|
style={{
|
||||||
fill: "#ffffff",
|
fill: "#ffffff",
|
||||||
fontSize: "16px",
|
fontSize: "12px",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
formatter={(v: number) =>
|
formatter={(v: number) => `${formatNumber(Math.round(v))}%`}
|
||||||
`${formatNumber(Math.round(v))}`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|
@ -10,12 +11,9 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
|
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
|
||||||
import { useStoredDate } from "~/hooks/useStoredDate";
|
|
||||||
import apiService from "~/lib/api";
|
import apiService from "~/lib/api";
|
||||||
import { EventBus, formatNumber } from "~/lib/utils";
|
import { formatNumber } from "~/lib/utils";
|
||||||
import type { CalendarDate } from "~/types/util.type";
|
|
||||||
|
|
||||||
export interface CompanyDetails {
|
export interface CompanyDetails {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -64,59 +62,38 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
|
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
|
||||||
const [processData, setProcessData] = useState<ProcessActorsData[]>([]);
|
const [processData, setProcessData] = useState<ProcessActorsData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
// const [date, setDate] = useState<CalendarDate>();
|
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (date: CalendarDate) => {
|
const fetchCounts = async () => {
|
||||||
if (date) setDate(date);
|
setIsLoading(true);
|
||||||
};
|
try {
|
||||||
|
const [countsRes, processRes] = await Promise.all([
|
||||||
|
apiService.call<EcosystemCounts>({
|
||||||
|
ecosystem_count_function: {},
|
||||||
|
}),
|
||||||
|
apiService.call<ProcessActorsResponse[]>({
|
||||||
|
process_creating_actors_function: {},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
setCounts(
|
||||||
|
JSON.parse(JSON.parse(countsRes.data).ecosystem_count_function)[0],
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
// Process the years data and fill missing years
|
||||||
EventBus.off("dateSelected", handler);
|
const processedData = processYearsData(
|
||||||
|
JSON.parse(JSON.parse(processRes?.data)?.process_creating_actors),
|
||||||
|
);
|
||||||
|
setProcessData(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch data:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
fetchCounts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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
|
// Helper function to safely parse numbers
|
||||||
const parseNumber = (value: string | undefined): number => {
|
const parseNumber = (value: string | undefined): number => {
|
||||||
if (!value || value === "") return 0;
|
if (!value || value === "") return 0;
|
||||||
|
|
@ -126,7 +103,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
|
|
||||||
// Helper function to process years data and fill missing years
|
// Helper function to process years data and fill missing years
|
||||||
const processYearsData = (
|
const processYearsData = (
|
||||||
data: ProcessActorsResponse[]
|
data: ProcessActorsResponse[],
|
||||||
): ProcessActorsData[] => {
|
): ProcessActorsData[] => {
|
||||||
if (!data || data.length === 0) return [];
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
|
|
@ -144,7 +121,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
acc[item.start_year] = item.total_count;
|
acc[item.start_year] = item.total_count;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>
|
{} as Record<string, number>,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let year = minYear; year <= maxYear; year++) {
|
for (let year = minYear; year <= maxYear; year++) {
|
||||||
|
|
@ -190,7 +167,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
{ label: "شتابدهنده", value: parseNumber(counts.accelerator_count) },
|
{ label: "شتابدهنده", value: parseNumber(counts.accelerator_count) },
|
||||||
{ label: "دانشگاه", value: parseNumber(counts.university_count) },
|
{ label: "دانشگاه", value: parseNumber(counts.university_count) },
|
||||||
{ label: "صندوق های مالی", value: parseNumber(counts.fund_count) },
|
{ label: "صندوق های مالی", value: parseNumber(counts.fund_count) },
|
||||||
{ label: "تامین کننده", value: parseNumber(counts.company_count) },
|
{ label: "شرکت", value: parseNumber(counts.company_count) },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
@ -279,7 +256,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute w-2 h-2 bg-pr-green rounded-full animate-pulse"
|
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
left: `${20 + i * 25}%`,
|
left: `${20 + i * 25}%`,
|
||||||
top: `${30 + Math.random() * 40}%`,
|
top: `${30 + Math.random() * 40}%`,
|
||||||
|
|
@ -310,7 +287,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
{/* Actor Count Skeleton */}
|
{/* Actor Count Skeleton */}
|
||||||
<CardHeader className="text-center pt-0 pb-4">
|
<CardHeader className="text-center pt-0 pb-4">
|
||||||
<div className="w-36 h-5 rounded animate-pulse mx-auto mb-2"></div>
|
<div className="w-36 h-5 rounded animate-pulse mx-auto mb-2"></div>
|
||||||
<div className="w-16 h-8 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
<div className="w-16 h-8 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Bar Chart Skeleton */}
|
{/* Bar Chart Skeleton */}
|
||||||
|
|
@ -385,7 +362,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute w-2 h-2 bg-pr-green rounded-full animate-pulse"
|
className="absolute w-2 h-2 bg-green-400 rounded-full animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
left: `${20 + i * 25}%`,
|
left: `${20 + i * 25}%`,
|
||||||
top: `${30 + Math.random() * 40}%`,
|
top: `${30 + Math.random() * 40}%`,
|
||||||
|
|
@ -401,7 +378,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
<CardContent className="pt-0 pb-6">
|
<CardContent className="pt-0 pb-6">
|
||||||
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center">
|
<div className="bg-[rgba(255,255,255,0.1)] rounded-lg p-4 text-center">
|
||||||
<div className="w-28 h-4 bg-gray-600 rounded animate-pulse mx-auto mb-1"></div>
|
<div className="w-28 h-4 bg-gray-600 rounded animate-pulse mx-auto mb-1"></div>
|
||||||
<div className="w-12 h-6 bg-pr-green bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
<div className="w-12 h-6 bg-green-400 bg-opacity-30 rounded animate-pulse mx-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -424,22 +401,30 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||||||
<CardHeader className="text-center pt-4 pb-3 border-b-2 border-[#3F415A]">
|
<CardHeader className="text-center pt-4 pb-3 border-b-2 border-[#3F415A]">
|
||||||
<CardTitle className="font-persian text-base font-semibold text-white">
|
<CardTitle className="font-persian text-xl text-white">
|
||||||
وضعیت زیستبوم فناوری و نوآوری
|
وضعیت زیستبوم فناوری و نوآوری
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
|
{/* Footer - MOU Count */}
|
||||||
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
|
{/* <CardContent className="py-3">
|
||||||
|
<div className="flex font-bold text-xl px-6 justify-between text-gray-300 font-persian mb-1">
|
||||||
تعداد تفاهم نامه ها
|
تعداد تفاهم نامه ها
|
||||||
<span className="font-bold text-3xl">
|
<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">
|
||||||
{formatNumber(counts.mou_count)}
|
{formatNumber(counts.mou_count)}
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
|
<CardHeader className="text-center pb-2 border-b-2 border-[#3F415A]">
|
||||||
<CardTitle className="font-persian text-sm text-white flex justify-between px-4">
|
<CardTitle className="font-persian text-xl text-white flex justify-between px-4">
|
||||||
تعداد بازیگران
|
تعداد بازیگران
|
||||||
<span className="font-bold text-3xl">
|
<span className="font-bold text-3xl">
|
||||||
{formatNumber(counts.actor_count)}
|
{formatNumber(counts.actor_count)}
|
||||||
|
|
@ -448,14 +433,13 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Actor Count Display */}
|
{/* Actor Count Display */}
|
||||||
<CardHeader className="text-right pt-4 mt-2 pb-2 text-sm font-semibold w-full">
|
<CardHeader className="text-right text-xl py-2 pb-4 font-bold w-full">
|
||||||
تنوع بازیگران
|
تنوع بازیگران
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{/* Middle - Bar Chart */}
|
{/* Middle - Bar Chart */}
|
||||||
<CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]">
|
<CardContent className="flex-1 px-6 border-b-2 border-[#3F415A]">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<CustomBarChart
|
<CustomBarChart
|
||||||
hasPercent={false}
|
|
||||||
data={barData.map((item) => ({
|
data={barData.map((item) => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
|
|
@ -470,87 +454,55 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Area Chart Section */}
|
{/* Area Chart Section */}
|
||||||
<CardContent className="p-2">
|
<CardContent className="px-2 pb-4 border-b-2 border-[#3F415A] py-4">
|
||||||
<div className="px-4">
|
<div className="mb-4">
|
||||||
<CardTitle className="font-persian text-sm font-semibold text-white mb-2">
|
<CardTitle className="font-persian text-lg text-white mb-2">
|
||||||
روند ایجاد بازیگران در طول سالها
|
روند ایجاد بازیگران در طول سالها
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-42">
|
<div className="h-48">
|
||||||
{processData.length > 0 ? (
|
{processData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
|
||||||
data={processData}
|
data={processData}
|
||||||
margin={{ top: 25, right: 30, left: 0, bottom: 0 }}
|
margin={{ top: 10, 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
|
<CartesianGrid
|
||||||
vertical={false}
|
strokeDasharray="3 3"
|
||||||
stroke="rgba(255,255,255,0.1)"
|
stroke="rgba(255,255,255,0.1)"
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="year"
|
dataKey="year"
|
||||||
stroke="#9ca3af"
|
stroke="#9ca3af"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tickLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={formatPersianYear}
|
tickFormatter={formatPersianYear}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="#9ca3af"
|
stroke="#9ca3af"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tickMargin={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => formatNumber(value)}
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
/>
|
/>
|
||||||
<Tooltip cursor={false} content={<></>} />
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
{/* ✅ Use gradient for fill */}
|
backgroundColor: "#374151",
|
||||||
|
border: "1px solid #6b7280",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) =>
|
||||||
|
`سال ${formatPersianYear(value.toString())}`
|
||||||
|
}
|
||||||
|
formatter={(value) => [
|
||||||
|
formatNumber(value),
|
||||||
|
"تعداد بازیگران",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="#3AEA83"
|
stroke="#34d399"
|
||||||
fill="url(#fillDesktop)"
|
fill="rgba(52, 211, 153, 0.25)"
|
||||||
strokeWidth={2}
|
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>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
@ -561,6 +513,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
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";
|
import apiService from "../../lib/api";
|
||||||
|
import { useAuth } from "../../contexts/auth-context";
|
||||||
|
|
||||||
|
// Get API base URL at module level to avoid process.env access in browser
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
//بندر امام
|
import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
|
||||||
// import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
|
|
||||||
//آپادانا
|
|
||||||
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
|
|
||||||
//نوری
|
|
||||||
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
|
|
||||||
|
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -52,9 +44,9 @@ export interface CompanyDetails {
|
||||||
|
|
||||||
export interface NetworkGraphProps {
|
export interface NetworkGraphProps {
|
||||||
onNodeClick?: (node: CompanyDetails) => void;
|
onNodeClick?: (node: CompanyDetails) => void;
|
||||||
onLoadingChange?: (loading: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to robustly parse backend response
|
||||||
function parseApiResponse(raw: any): any[] {
|
function parseApiResponse(raw: any): any[] {
|
||||||
let data = raw;
|
let data = raw;
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,14 +56,12 @@ function parseApiResponse(raw: any): any[] {
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we're in browser environment
|
||||||
function isBrowser(): boolean {
|
function isBrowser(): boolean {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkGraph({
|
export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
|
||||||
onNodeClick,
|
|
||||||
onLoadingChange,
|
|
||||||
}: NetworkGraphProps) {
|
|
||||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
const [links, setLinks] = useState<Link[]>([]);
|
const [links, setLinks] = useState<Link[]>([]);
|
||||||
|
|
@ -80,21 +70,7 @@ export function NetworkGraph({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
// const [date, setDate] = useState<CalendarDate>();
|
// Ensure component only renders on client side
|
||||||
|
|
||||||
const [date, setDate] = useStoredDate();
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (date: CalendarDate) => {
|
|
||||||
if (date) setDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
EventBus.on("dateSelected", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
EventBus.off("dateSelected", handler);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isBrowser()) {
|
if (isBrowser()) {
|
||||||
const timer = setTimeout(() => setIsMounted(true), 100);
|
const timer = setTimeout(() => setIsMounted(true), 100);
|
||||||
|
|
@ -102,27 +78,7 @@ export function NetworkGraph({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getImageUrl = useCallback(
|
// Fetch data from API
|
||||||
(stageid: number) => {
|
|
||||||
if (!token?.accessToken) return null;
|
|
||||||
return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`;
|
|
||||||
},
|
|
||||||
[token?.accessToken]
|
|
||||||
);
|
|
||||||
|
|
||||||
const callAPI = useCallback(
|
|
||||||
async (stage_id: number) => {
|
|
||||||
return await apiService.call<any>({
|
|
||||||
get_values_workflow_function: {
|
|
||||||
stage_id: stage_id,
|
|
||||||
// start_date: date?.start || null,
|
|
||||||
// end_date: date?.end || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[date]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
|
@ -133,45 +89,28 @@ export function NetworkGraph({
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiService.call<any[]>({
|
const res = await apiService.call<any[]>({
|
||||||
graph_production_function: {
|
graph_production_function: {},
|
||||||
start_date: date.start || null,
|
|
||||||
end_date: date.end || null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
|
||||||
const data = parseApiResponse(JSON.parse(res.data)?.graph_production);
|
const data = parseApiResponse(JSON.parse(res.data)?.graph_production);
|
||||||
console.log(
|
console.log(
|
||||||
"All available fields in first item:",
|
"All available fields in first item:",
|
||||||
Object.keys(data[0] || {})
|
Object.keys(data[0] || {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// نود مرکزی
|
// Create center node
|
||||||
const centerNode: Node = {
|
const centerNode: Node = {
|
||||||
id: "center",
|
id: "center",
|
||||||
// label: "پتروشیمی بندر امام",
|
label: "پتروشیمی بندر امام", //مرکز زیست بوم
|
||||||
// label: "پتروشیمی نوری",
|
|
||||||
label: "پتروشیمی آپادانا",
|
|
||||||
category: "center",
|
category: "center",
|
||||||
stageid: 0,
|
stageid: 0,
|
||||||
isCenter: true,
|
isCenter: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// دستهبندیها
|
// Create ecosystem nodes
|
||||||
const categories = Array.from(
|
const ecosystemNodes: Node[] = data.map((item: any) => ({
|
||||||
new Set(data.map((item: any) => item.category))
|
id: String(item.stageid),
|
||||||
);
|
|
||||||
|
|
||||||
const categoryNodes: Node[] = categories.map((cat, index) => ({
|
|
||||||
id: `cat-${index}`,
|
|
||||||
label: cat,
|
|
||||||
category: cat,
|
|
||||||
stageid: -1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// نودهای نهایی
|
|
||||||
const finalNodes: Node[] = data.map((item: any) => ({
|
|
||||||
id: `node-${item.stageid}`,
|
|
||||||
label: item.title,
|
label: item.title,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
stageid: item.stageid,
|
stageid: item.stageid,
|
||||||
|
|
@ -179,16 +118,13 @@ export function NetworkGraph({
|
||||||
rawData: item,
|
rawData: item,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// لینکها: مرکز → دستهبندیها → نودهای نهایی
|
// Create links (all nodes connected to center)
|
||||||
const graphLinks: Link[] = [
|
const graphLinks: Link[] = ecosystemNodes.map((node) => ({
|
||||||
...categoryNodes.map((cat) => ({ source: "center", target: cat.id })),
|
source: "center",
|
||||||
...finalNodes.map((node) => {
|
target: node.id,
|
||||||
const catIndex = categories.indexOf(node.category);
|
}));
|
||||||
return { source: `cat-${catIndex}`, target: node.id };
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
setNodes([centerNode, ...categoryNodes, ...finalNodes]);
|
setNodes([centerNode, ...ecosystemNodes]);
|
||||||
setLinks(graphLinks);
|
setLinks(graphLinks);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name !== "AbortError") {
|
if (err.name !== "AbortError") {
|
||||||
|
|
@ -206,19 +142,43 @@ export function NetworkGraph({
|
||||||
aborted = true;
|
aborted = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [isMounted, token, getImageUrl, date]);
|
}, [isMounted, token]);
|
||||||
|
|
||||||
|
// Get image URL for a node
|
||||||
|
const getImageUrl = useCallback(
|
||||||
|
(stageid: number) => {
|
||||||
|
if (!token?.accessToken) return null;
|
||||||
|
return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`;
|
||||||
|
},
|
||||||
|
[token?.accessToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import apiService for the onClick handler
|
||||||
|
const callAPI = useCallback(async (stage_id: number) => {
|
||||||
|
return await apiService.call<any>({
|
||||||
|
get_values_workflow_function: {
|
||||||
|
stage_id: stage_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize D3 graph
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0)
|
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const svg = d3.select(svgRef.current);
|
const svg = d3.select(svgRef.current);
|
||||||
const width = svgRef.current.clientWidth;
|
const width = svgRef.current.clientWidth;
|
||||||
const height = svgRef.current.clientHeight;
|
const height = svgRef.current.clientHeight;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
svg.selectAll("*").remove();
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
// Create defs for patterns and filters
|
||||||
const defs = svg.append("defs");
|
const defs = svg.append("defs");
|
||||||
|
|
||||||
|
// Add glow filter for hover effect
|
||||||
const filter = defs
|
const filter = defs
|
||||||
.append("filter")
|
.append("filter")
|
||||||
.attr("id", "glow")
|
.attr("id", "glow")
|
||||||
|
|
@ -236,27 +196,33 @@ export function NetworkGraph({
|
||||||
feMerge.append("feMergeNode").attr("in", "coloredBlur");
|
feMerge.append("feMergeNode").attr("in", "coloredBlur");
|
||||||
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
||||||
|
|
||||||
const container = svg.append("g");
|
// Create zoom behavior
|
||||||
|
|
||||||
const zoom = d3
|
const zoom = d3
|
||||||
.zoom<SVGSVGElement, unknown>()
|
.zoom<SVGSVGElement, unknown>()
|
||||||
.scaleExtent([0.3, 2.5])
|
.scaleExtent([0.8, 2.5]) // Limit zoom out to 1x, zoom in to 2.5x
|
||||||
.on("zoom", (event) => container.attr("transform", event.transform));
|
.on("zoom", (event) => {
|
||||||
|
container.attr("transform", event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
svg.call(zoom);
|
svg.call(zoom);
|
||||||
|
|
||||||
|
// Create container group
|
||||||
|
const container = svg.append("g");
|
||||||
|
|
||||||
|
// Category colors
|
||||||
const categoryToColor: Record<string, string> = {
|
const categoryToColor: Record<string, string> = {
|
||||||
دانشگاه: "#3B82F6",
|
دانشگاه: "#3B82F6",
|
||||||
مشاور: "#10B981",
|
مشاور: "#10B981",
|
||||||
"دانش بنیان": "#F59E0B",
|
"دانش بنیان": "#F59E0B",
|
||||||
استارتاپ: "#EF4444",
|
استارتاپ: "#EF4444",
|
||||||
"تامین کننده": "#8B5CF6",
|
شرکت: "#8B5CF6",
|
||||||
صندوق: "#06B6D4",
|
صندوق: "#06B6D4",
|
||||||
شتابدهنده: "#9333EA",
|
شتابدهنده: "#9333EA",
|
||||||
"مرکز نوآوری": "#F472B6",
|
"مرکز نوآوری": "#F472B6",
|
||||||
center: "#34D399",
|
center: "#34D399",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create force simulation
|
||||||
const simulation = d3
|
const simulation = d3
|
||||||
.forceSimulation<Node>(nodes)
|
.forceSimulation<Node>(nodes)
|
||||||
.force(
|
.force(
|
||||||
|
|
@ -265,21 +231,16 @@ export function NetworkGraph({
|
||||||
.forceLink<Node, Link>(links)
|
.forceLink<Node, Link>(links)
|
||||||
.id((d) => d.id)
|
.id((d) => d.id)
|
||||||
.distance(150)
|
.distance(150)
|
||||||
.strength(0.2)
|
.strength(0.1),
|
||||||
)
|
)
|
||||||
.force("charge", d3.forceManyBody().strength(-300))
|
.force("charge", d3.forceManyBody().strength(-300))
|
||||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
.force(
|
|
||||||
"radial",
|
|
||||||
d3.forceRadial((d) => (d.isCenter ? 0 : 300), width / 2, height / 2)
|
|
||||||
)
|
|
||||||
.force(
|
.force(
|
||||||
"collision",
|
"collision",
|
||||||
d3.forceCollide().radius((d) => (d.isCenter ? 50 : 35))
|
d3.forceCollide().radius((d) => (d.isCenter ? 40 : 30)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial zoom to show entire graph
|
const initialScale = 0.85;
|
||||||
const initialScale = 0.6;
|
|
||||||
const initialTranslate = [
|
const initialTranslate = [
|
||||||
width / 2 - (width / 2) * initialScale,
|
width / 2 - (width / 2) * initialScale,
|
||||||
height / 2 - (height / 2) * initialScale,
|
height / 2 - (height / 2) * initialScale,
|
||||||
|
|
@ -288,69 +249,37 @@ export function NetworkGraph({
|
||||||
zoom.transform,
|
zoom.transform,
|
||||||
d3.zoomIdentity
|
d3.zoomIdentity
|
||||||
.translate(initialTranslate[0], initialTranslate[1])
|
.translate(initialTranslate[0], initialTranslate[1])
|
||||||
.scale(initialScale)
|
.scale(initialScale),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fix center node
|
// Fix center node position
|
||||||
const centerNode = nodes.find((n) => n.isCenter);
|
const centerNode = nodes.find((n) => n.isCenter);
|
||||||
const categoryNodes = nodes.filter((n) => !n.isCenter && n.stageid === -1);
|
|
||||||
|
|
||||||
if (centerNode) {
|
if (centerNode) {
|
||||||
const centerX = width / 2;
|
centerNode.fx = width / 2;
|
||||||
const centerY = height / 2;
|
centerNode.fy = 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// نودهای نهایی **هیچ fx/fy نداشته باشند**
|
// Create links
|
||||||
// فقط forceLink آنها را به دستهها متصل نگه میدارد
|
|
||||||
|
|
||||||
// const finalNodes = nodes.filter(n => !n.isCenter && n.stageid !== -1);
|
|
||||||
|
|
||||||
// categoryNodes.forEach((catNode) => {
|
|
||||||
// const childNodes = finalNodes.filter(n => n.category === catNode.category);
|
|
||||||
// const childCount = childNodes.length;
|
|
||||||
// const radius = 100; // فاصله از دسته
|
|
||||||
// const angleStep = (2 * Math.PI) / childCount;
|
|
||||||
|
|
||||||
// childNodes.forEach((node, i) => {
|
|
||||||
// const angle = i * angleStep;
|
|
||||||
// node.fx = catNode.fx! + radius * Math.cos(angle);
|
|
||||||
// node.fy = catNode.fy! + radius * Math.sin(angle);
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Curved links
|
|
||||||
const link = container
|
const link = container
|
||||||
.selectAll(".link")
|
.selectAll(".link")
|
||||||
.data(links)
|
.data(links)
|
||||||
.enter()
|
.enter()
|
||||||
.append("path")
|
.append("line")
|
||||||
.attr("class", "link")
|
.attr("class", "link")
|
||||||
.attr("stroke", "#E2E8F0")
|
.attr("stroke", "#E2E8F0")
|
||||||
.attr("stroke-width", 2)
|
.attr("stroke-width", 2)
|
||||||
.attr("stroke-opacity", 0.6)
|
.attr("stroke-opacity", 0.6);
|
||||||
.attr("fill", "none");
|
|
||||||
|
|
||||||
|
// Create node groups
|
||||||
const nodeGroup = container
|
const nodeGroup = container
|
||||||
.selectAll(".node")
|
.selectAll(".node")
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
.enter()
|
.enter()
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("class", "node")
|
.attr("class", "node")
|
||||||
.style("cursor", (d) => (d.stageid === -1 ? "default" : "pointer"));
|
.style("cursor", "pointer");
|
||||||
|
|
||||||
|
// Add drag behavior
|
||||||
const drag = d3
|
const drag = d3
|
||||||
.drag<SVGGElement, Node>()
|
.drag<SVGGElement, Node>()
|
||||||
.on("start", (event, d) => {
|
.on("start", (event, d) => {
|
||||||
|
|
@ -372,100 +301,56 @@ export function NetworkGraph({
|
||||||
|
|
||||||
nodeGroup.call(drag);
|
nodeGroup.call(drag);
|
||||||
|
|
||||||
|
// Add node circles/rectangles
|
||||||
nodeGroup.each(function (d) {
|
nodeGroup.each(function (d) {
|
||||||
const group = d3.select(this);
|
const group = d3.select(this);
|
||||||
|
|
||||||
// if (d.isCenter) {
|
if (d.isCenter) {
|
||||||
// const rect = group
|
// Center node as rectangle
|
||||||
// .append("rect")
|
const rect = group
|
||||||
// .attr("width", 200)
|
.append("rect")
|
||||||
// .attr("height", 80)
|
.attr("width", 150)
|
||||||
// .attr("x", -100) // نصف عرض جدید منفی
|
.attr("height", 60)
|
||||||
// .attr("y", -40) // نصف ارتفاع جدید منفی
|
.attr("x", -75)
|
||||||
// .attr("rx", 8)
|
.attr("y", -30)
|
||||||
// .attr("ry", 8)
|
.attr("rx", 8)
|
||||||
// .attr("fill", categoryToColor[d.category] || "#94A3B8")
|
.attr("ry", 8)
|
||||||
// .attr("stroke", "#FFFFFF")
|
.attr("fill", categoryToColor[d.category] || "#94A3B8")
|
||||||
// .attr("stroke-width", 3)
|
.attr("stroke", "#FFFFFF")
|
||||||
// .style("pointer-events", "none");
|
.attr("stroke-width", 3)
|
||||||
|
.style("pointer-events", "none");
|
||||||
|
|
||||||
// if (d.imageUrl || d.isCenter) {
|
// Add center image if available
|
||||||
// const pattern = defs
|
if (d.imageUrl || d.isCenter) {
|
||||||
// .append("pattern")
|
const pattern = defs
|
||||||
// .attr("id", `image-${d.id}`)
|
.append("pattern")
|
||||||
// .attr("x", 0)
|
.attr("id", `image-${d.id}`)
|
||||||
// .attr("y", 0)
|
.attr("x", 0)
|
||||||
// .attr("width", 1)
|
.attr("y", 0)
|
||||||
// .attr("height", 1);
|
.attr("width", 1)
|
||||||
|
.attr("height", 1);
|
||||||
|
|
||||||
// pattern
|
pattern
|
||||||
// .append("image")
|
.append("image")
|
||||||
// .attr("x", 0)
|
.attr("x", 0)
|
||||||
// .attr("y", 0)
|
.attr("y", 0)
|
||||||
// .attr("width", 200) // ← هماندازه با مستطیل
|
.attr("width", 150)
|
||||||
// .attr("height", 80)
|
.attr("height", 60)
|
||||||
// .attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
|
.attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
|
||||||
// .attr("preserveAspectRatio", "xMidYMid slice");
|
.attr("preserveAspectRatio", "xMidYMid slice");
|
||||||
|
|
||||||
// rect.attr("fill", `url(#image-${d.id})`);
|
rect.attr("fill", `url(#image-${d.id})`);
|
||||||
// }
|
}
|
||||||
// }
|
} else {
|
||||||
// راه حل سادهتر - ابعاد ثابت با حفظ نسبت
|
// Regular nodes as circles
|
||||||
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
|
const circle = group
|
||||||
.append("circle")
|
.append("circle")
|
||||||
.attr("r", 25)
|
.attr("r", 25)
|
||||||
.attr("fill", categoryToColor[d.category] || "#fff")
|
.attr("fill", categoryToColor[d.category] || "8#fff")
|
||||||
.attr("stroke", "#FFFFFF")
|
.attr("stroke", "#FFFFFF")
|
||||||
.attr("stroke-width", 3);
|
.attr("stroke-width", 3);
|
||||||
|
|
||||||
|
// Add node image if available
|
||||||
if (d.imageUrl) {
|
if (d.imageUrl) {
|
||||||
const pattern = defs
|
const pattern = defs
|
||||||
.append("pattern")
|
.append("pattern")
|
||||||
|
|
@ -482,8 +367,10 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
.attr("width", 50)
|
.attr("width", 50)
|
||||||
.attr("height", 50)
|
.attr("height", 50)
|
||||||
.attr("href", d.imageUrl)
|
.attr("href", d.imageUrl)
|
||||||
|
.attr("backgroundColor", "#fff")
|
||||||
.attr("preserveAspectRatio", "xMidYMid slice");
|
.attr("preserveAspectRatio", "xMidYMid slice");
|
||||||
|
|
||||||
|
// Create circular clip path
|
||||||
defs
|
defs
|
||||||
.append("clipPath")
|
.append("clipPath")
|
||||||
.attr("id", `clip-${d.id}`)
|
.attr("id", `clip-${d.id}`)
|
||||||
|
|
@ -497,33 +384,20 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add labels below nodes
|
||||||
const labels = nodeGroup
|
const labels = nodeGroup
|
||||||
.append("text")
|
.append("text")
|
||||||
.text((d) => d.label)
|
.text((d) => d.label)
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("dy", (d) => {
|
.attr("dy", (d) => (d.isCenter ? 50 : 45))
|
||||||
if (d.isCenter) {
|
.attr("font-size", (d) => (d.isCenter ? "14px" : "12px"))
|
||||||
|
.attr("font-weight", "bold")
|
||||||
//آپادانا
|
.attr("fill", "#F9FAFB")
|
||||||
const centerNodeHeight = 200; // ارتفاع نود مرکزی
|
.attr("stroke", "rgba(17, 24, 39, 0.95)")
|
||||||
|
.attr("stroke-width", 4)
|
||||||
//بندر امام
|
.attr("paint-order", "stroke");
|
||||||
// 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
|
nodeGroup
|
||||||
.on("mouseenter", function (event, d) {
|
.on("mouseenter", function (event, d) {
|
||||||
if (d.isCenter) return;
|
if (d.isCenter) return;
|
||||||
|
|
@ -545,88 +419,79 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
.attr("stroke-width", 3);
|
.attr("stroke-width", 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
nodeGroup.on("click", async function (event, d) {
|
nodeGroup.on("click", async function (event, d) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// جلوگیری از کلیک روی مرکز و دستهبندیها
|
// Don't handle center node clicks
|
||||||
if (d.isCenter || d.stageid === -1) return;
|
if (d.isCenter) return;
|
||||||
|
|
||||||
if (onNodeClick && d.stageid) {
|
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 {
|
try {
|
||||||
if (date.start && date.end) {
|
// Fetch detailed company data
|
||||||
const res = await callAPI(d.stageid);
|
const res = await callAPI(d.stageid);
|
||||||
const responseData = JSON.parse(res.data);
|
|
||||||
const fieldValues =
|
|
||||||
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
|
|
||||||
|
|
||||||
const filteredFields = fieldValues.filter(
|
const responseData = JSON.parse(res.data);
|
||||||
(field: any) =>
|
const fieldValues =
|
||||||
!["image", "img", "full_name", "about_collaboration"].includes(
|
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
|
||||||
field.F.toLowerCase()
|
// Filter out image fields and find description
|
||||||
)
|
const filteredFields = fieldValues.filter(
|
||||||
);
|
(field: any) =>
|
||||||
|
!["image", "img", "full_name", "about_collaboration"].includes(
|
||||||
|
field.F.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const descriptionField = fieldValues.find(
|
const descriptionField = fieldValues.find(
|
||||||
(field: any) =>
|
(field: any) =>
|
||||||
field.F.toLowerCase().includes("description") ||
|
field.F.toLowerCase().includes("description") ||
|
||||||
field.F.toLowerCase().includes("about_collaboration") ||
|
field.F.toLowerCase().includes("about_collaboration") ||
|
||||||
field.F.toLowerCase().includes("about")
|
field.F.toLowerCase().includes("about"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const companyDetails: CompanyDetails = {
|
const companyDetails: CompanyDetails = {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
label: d.label,
|
label: d.label,
|
||||||
category: d.category,
|
category: d.category,
|
||||||
stageid: d.stageid,
|
stageid: d.stageid,
|
||||||
fields: filteredFields,
|
fields: filteredFields,
|
||||||
description: descriptionField?.V || undefined,
|
description: descriptionField?.V || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
onNodeClick(companyDetails);
|
onNodeClick(companyDetails);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch company details:", error);
|
console.error("Failed to fetch company details:", error);
|
||||||
// Keep the basic details already shown
|
// Fallback to basic info
|
||||||
} finally {
|
const basicDetails: CompanyDetails = {
|
||||||
// Stop loading
|
id: d.id,
|
||||||
onLoadingChange?.(false);
|
label: d.label,
|
||||||
|
category: d.category,
|
||||||
|
stageid: d.stageid,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
onNodeClick(basicDetails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update positions on simulation tick
|
||||||
simulation.on("tick", () => {
|
simulation.on("tick", () => {
|
||||||
link.attr("d", (d: any) => {
|
link
|
||||||
const sx = (d.source as Node).x!;
|
.attr("x1", (d) => (d.source as Node).x!)
|
||||||
const sy = (d.source as Node).y!;
|
.attr("y1", (d) => (d.source as Node).y!)
|
||||||
const tx = (d.target as Node).x!;
|
.attr("x2", (d) => (d.target as Node).x!)
|
||||||
const ty = (d.target as Node).y!;
|
.attr("y2", (d) => (d.target as Node).y!);
|
||||||
const dx = tx - sx;
|
|
||||||
const dy = ty - sy;
|
|
||||||
const dr = Math.sqrt(dx * dx + dy * dy) * 1.2; // منحنی
|
|
||||||
return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
simulation.stop();
|
simulation.stop();
|
||||||
};
|
};
|
||||||
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI, date]);
|
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]);
|
||||||
|
|
||||||
|
// Show error message
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||||
|
|
@ -640,9 +505,10 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't render on server side
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-transparent">
|
<div className="w-full h-full flex items-center justify-center bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||||||
<div className="text-white font-persian text-sm">
|
<div className="text-white font-persian text-sm">
|
||||||
در حال بارگذاری...
|
در حال بارگذاری...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -652,12 +518,15 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative bg-transparent">
|
<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 flex items-center justify-center relative">
|
<div className="w-full h-full flex items-center justify-center relative">
|
||||||
|
{/* Center Node Skeleton */}
|
||||||
<div className="w-12 h-12 rounded-lg bg-gray-600 animate-pulse relative z-10">
|
<div className="w-12 h-12 rounded-lg bg-gray-600 animate-pulse relative z-10">
|
||||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
|
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Outer Ring Nodes Skeleton */}
|
||||||
{Array.from({ length: 8 }).map((_, i) => {
|
{Array.from({ length: 8 }).map((_, i) => {
|
||||||
const angle = (i * 2 * Math.PI) / 8;
|
const angle = (i * 2 * Math.PI) / 8;
|
||||||
const radius = 120;
|
const radius = 120;
|
||||||
|
|
@ -678,25 +547,40 @@ const fixedHeight = 200; // یا میتوانید براساس نسبت تص
|
||||||
<div
|
<div
|
||||||
className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse"
|
className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
transform: `rotate(${(i * 360) / 8}deg) translateX(32px)`,
|
left: "50%",
|
||||||
transformOrigin: "left center",
|
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`,
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
|
||||||
|
<div className="text-white font-persian text-sm animate-pulse">
|
||||||
|
در حال بارگذاری نمودار...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full relative bg-[linear-gradient(to_bottom_left,#464861,10%,#111628)] overflow-hidden">
|
||||||
<svg
|
<svg ref={svgRef} className="w-full h-full" style={{ minHeight: 500 }} />
|
||||||
ref={svgRef}
|
|
||||||
className="w-full h-full bg-transparent"
|
|
||||||
style={{ cursor: "grab" }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useId } from "react";
|
|
||||||
|
|
||||||
interface CheckboxProps {
|
|
||||||
checked: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange?: (checked: boolean) => void;
|
|
||||||
className?: string;
|
|
||||||
id ?:string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CustomCheckBox({
|
|
||||||
checked,
|
|
||||||
disabled = false,
|
|
||||||
onChange,
|
|
||||||
className = "",
|
|
||||||
id
|
|
||||||
}: CheckboxProps) {
|
|
||||||
|
|
||||||
const handleChange = (e: any) => {
|
|
||||||
onChange?.(e.target.checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={handleChange}
|
|
||||||
className={`form-checkbox ${className}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./card";
|
|
||||||
|
|
||||||
interface BaseCardProps {
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
headerClassName?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
icon?: React.ComponentType<{ className?: string }>;
|
|
||||||
withHeader?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BaseCard({
|
|
||||||
title,
|
|
||||||
className,
|
|
||||||
headerClassName,
|
|
||||||
contentClassName,
|
|
||||||
children,
|
|
||||||
withHeader = false,
|
|
||||||
icon: Icon,
|
|
||||||
}: BaseCardProps) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm py-2 pb-0 grid items-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<CardContent className={cn("py-2 px-4 ", contentClassName)}>
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm ",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { calculateNiceRange, formatNumber } from "~/lib/utils";
|
import { formatNumber } from "~/lib/utils";
|
||||||
|
|
||||||
export interface BarChartData {
|
export interface BarChartData {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -18,7 +18,6 @@ interface CustomBarChartProps {
|
||||||
showAxisLabels?: boolean;
|
showAxisLabels?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
hasPercent?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomBarChart({
|
export function CustomBarChart({
|
||||||
|
|
@ -29,17 +28,16 @@ export function CustomBarChart({
|
||||||
showAxisLabels = true,
|
showAxisLabels = true,
|
||||||
className = "",
|
className = "",
|
||||||
loading = false,
|
loading = false,
|
||||||
hasPercent = true,
|
|
||||||
}: CustomBarChartProps) {
|
}: CustomBarChartProps) {
|
||||||
// استفاده از nice numbers برای محاسبه دامنه مناسب
|
// Calculate the maximum value across all data points for consistent scaling
|
||||||
const values = data.map((item) => item.maxValue || item.value);
|
const globalMaxValue = Math.max(
|
||||||
const { niceMax, ticks } = calculateNiceRange(values, 0, 5);
|
...data.map((item) => item.maxValue || item.value)
|
||||||
const globalMaxValue = niceMax;
|
);
|
||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 p-4 pt-0 ${className}`} style={{ height }}>
|
<div className={`space-y-6 p-4 ${className}`} style={{ height }}>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="h-7 bg-gray-600 rounded animate-pulse mb-4 w-1/2"></div>
|
<div className="h-7 bg-gray-600 rounded animate-pulse mb-4 w-1/2"></div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -69,67 +67,92 @@ export function CustomBarChart({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`} style={{ height }}>
|
<div className={`space-y-6 ${className}`} style={{ height }}>
|
||||||
{title && (
|
<div className="border-b">
|
||||||
<div className="border-b-[#3F415A] border-b-2">
|
{title && (
|
||||||
<h3 className="text-sm font-semibold text-white font-persian text-right px-4 pb-3">
|
<h3 className="text-xl font-bold text-white font-persian text-right p-4">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-4 pb-4">
|
<div className="space-y-4 px-4 pb-4">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
// محاسبه درصد بر اساس nice max value
|
|
||||||
const percentage =
|
const percentage =
|
||||||
globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0;
|
globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0;
|
||||||
const displayValue: any = item.value;
|
const displayValue: any = item.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-3">
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
{/* Label */}
|
||||||
<span
|
<span
|
||||||
className={`font-persian text-sm font-normal min-w-[120px] text-left ${
|
className={`font-persian text-sm min-w-[160px] text-right ${
|
||||||
item.labelColor || "text-white"
|
item.labelColor || "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Bar Container */}
|
||||||
<div
|
<div
|
||||||
className={`${showAxisLabels && "bg-pr-gray"} flex-1 flex items-center gap-1 justify-start rounded-full overflow-hidden ${barHeight}`}
|
className={`flex-1 flex items-center bg-gray-700 rounded-full relative overflow-hidden ${barHeight}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${barHeight} rounded-full transition-all duration-700 ease-out ${
|
className={`${barHeight} rounded-full transition-all duration-700 ease-out relative ${
|
||||||
item.color || "bg-pr-green"
|
item.color || "bg-emerald-400"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(percentage, 100)}%`,
|
width: `${Math.min(percentage, 100)}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="inset-0 bg-gradient-to-r from-transparent to-white/10 rounded-full"></div>
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
<span className={`text-base font-normal text-left text-white`}>
|
|
||||||
{item.valuePrefix || ""}
|
|
||||||
|
|
||||||
{formatNumber(parseFloat(displayValue))}
|
|
||||||
{hasPercent ? "%" : ""}
|
|
||||||
{item.valueSuffix || ""}
|
|
||||||
</span>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Axis Labels با استفاده از nice numbers */}
|
{/* Axis Labels */}
|
||||||
{showAxisLabels && globalMaxValue > 0 && (
|
{showAxisLabels && globalMaxValue > 0 && (
|
||||||
<div className="flex w-full items-center gap-3 mt-6">
|
<div className="flex items-center gap-3 mt-6">
|
||||||
<span className="min-w-[120px]"></span>
|
<span className="min-w-[160px]"></span>
|
||||||
<div className="flex-1 flex justify-between pt-2 border-t border-gray-700">
|
<div className="flex-1 flex justify-between pt-2 border-t border-gray-700">
|
||||||
{ticks.map((tick, index) => (
|
<span className="text-gray-400 text-xs">{formatNumber(0)}</span>
|
||||||
<span key={index} className="text-gray-400 text-xs">
|
<span className="text-gray-400 text-xs">
|
||||||
{formatNumber(tick)}%
|
{formatNumber(Math.round(globalMaxValue / 4))}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-[0px]"></span>
|
<span className="min-w-[60px]"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as React from "react"
|
||||||
import { X } from "lucide-react";
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import * as React from "react";
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
|
@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
|
@ -38,20 +38,20 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background 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 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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute left-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-6 w-6 cursor-pointer" />
|
<X className="h-4 w-4 cursor-pointer" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
))
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
|
|
@ -59,13 +59,13 @@ const DialogHeader = ({
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col p-4 space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
DialogHeader.displayName = "DialogHeader";
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
|
|
@ -78,8 +78,8 @@ const DialogFooter = ({
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
DialogFooter.displayName = "DialogFooter";
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
|
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
|
@ -105,18 +105,18 @@ const DialogDescription = React.forwardRef<
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
};
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as React from "react"
|
||||||
import { Check, ChevronDown, Circle } from "lucide-react";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
import * as React from "react";
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
|
@ -34,10 +34,11 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
))
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
|
@ -51,9 +52,9 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName;
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
|
@ -64,34 +65,32 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
));
|
))
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean
|
||||||
selected?: boolean;
|
|
||||||
}
|
}
|
||||||
>(({ className, inset, selected, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
selected && "bg-dark-blue text-white",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
|
@ -113,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
));
|
))
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
|
@ -136,13 +135,13 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
));
|
))
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
|
|
@ -154,8 +153,8 @@ const DropdownMenuLabel = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
|
@ -166,8 +165,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
|
|
@ -178,43 +177,24 @@ const DropdownMenuShortcut = ({
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
const DropdownMenuButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between gap-2 text-sm outline-none border border-gray-500 p-3 rounded-xl min-w-50 max-w-72 group",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
|
||||||
</DropdownMenuPrimitive.Trigger>
|
|
||||||
));
|
|
||||||
DropdownMenuButton.displayName = "DropdownMenuButton";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuButton,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuRadioGroup,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { cn } from "~/lib/utils";
|
||||||
import { Eye, EyeOff, AlertCircle, CheckCircle2 } from "lucide-react";
|
import { Eye, EyeOff, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { Input } from "./input";
|
import { Input } from "./input";
|
||||||
import { Label } from "./label";
|
import { Label } from "./label";
|
||||||
import CustomCheckbox from "./CustomCheckBox";
|
|
||||||
|
|
||||||
interface BaseFieldProps {
|
interface BaseFieldProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -66,6 +65,12 @@ export function TextField({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
|
||||||
|
{leftIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
|
|
@ -77,14 +82,39 @@ export function TextField({
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-12 outline-none bg-white text-base text-[#5F6284] px-4 font-persian text-right transition-all duration-200",
|
"w-full h-12 px-4 font-persian text-right transition-all duration-200",
|
||||||
|
leftIcon && "pr-10",
|
||||||
|
(rightIcon || hasError || hasSuccess) && "pl-10",
|
||||||
|
hasError &&
|
||||||
|
"border-destructive focus:border-destructive focus:ring-destructive/20",
|
||||||
|
hasSuccess &&
|
||||||
|
"border-green-500 focus:border-green-500 focus:ring-green-500/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{boxShadow : "none"}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(rightIcon || hasError || hasSuccess) && (
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
{hasError ? (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
) : hasSuccess ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
rightIcon && (
|
||||||
|
<span className="text-muted-foreground">{rightIcon}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive font-persian flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{helper && !error && (
|
{helper && !error && (
|
||||||
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -187,19 +217,17 @@ export function PasswordField({
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-12 px-4 pl-10 bg-white text-base text-[#5F6284] font-persian text-right transition-all duration-200",
|
"w-full h-12 px-4 pl-10 font-persian text-right transition-all duration-200",
|
||||||
hasError &&
|
hasError &&
|
||||||
"border-destructive focus:border-destructive focus:ring-destructive/20",
|
"border-destructive focus:border-destructive focus:ring-destructive/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{boxShadow : "none"}}
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-black transition-colors"
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
|
|
@ -290,17 +318,26 @@ export function CheckboxField({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", containerClassName)}>
|
<div className={cn("space-y-2", containerClassName)}>
|
||||||
<div className="flex flex-row-reverse items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CustomCheckbox
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={onChange}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
sizes[size],
|
||||||
|
"text-[var(--color-login-primary)] bg-background border-input rounded focus:ring-[var(--color-login-primary)] focus:ring-2 accent-[var(--color-login-primary)] transition-all duration-200",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
error && "border-destructive focus:ring-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{label && (
|
{label && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-persian font-light text-white cursor-pointer",
|
"text-sm font-persian cursor-pointer",
|
||||||
error ? "text-destructive" : "text-foreground",
|
error ? "text-destructive" : "text-foreground",
|
||||||
disabled && "opacity-50 cursor-not-allowed",
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
required &&
|
required &&
|
||||||
|
|
|
||||||
39
app/components/ui/funnel-chart.test.tsx
Normal file
39
app/components/ui/funnel-chart.test.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { FunnelChart } from './funnel-chart';
|
||||||
|
|
||||||
|
const mockData = [
|
||||||
|
{ name: "تعداد کل", value: 250, label: "تعداد کل" },
|
||||||
|
{ name: "نمونه موفق", value: 130, label: "نمونه موفق" },
|
||||||
|
{ name: "محصولات موفق", value: 70, label: "محصولات موفق" },
|
||||||
|
{ name: "بهبود یا تغییر موفق", value: 80, label: "بهبود یا تغییر موفق" },
|
||||||
|
{ name: "محصول جدید", value: 50, label: "محصول جدید" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('FunnelChart', () => {
|
||||||
|
it('renders funnel chart with correct data', () => {
|
||||||
|
render(<FunnelChart data={mockData} title="قيف فرآیند پروژه ها" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('قيف فرآیند پروژه ها')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۱۰۰%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۲۵%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ابتدا فرآیند')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('انتها فرآیند')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays funnel data values correctly', () => {
|
||||||
|
render(<FunnelChart data={mockData} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('۲۵۰')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۱۳۰')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۷۰')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۸۰')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۵۰')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without title when not provided', () => {
|
||||||
|
render(<FunnelChart data={mockData} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('قيف فرآیند پروژه ها')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,6 @@ interface FunnelChartProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
const greenColors = ["#3C9F71","#3BC47A","#3BC47A","#3BD77E","#3AEA83"]
|
|
||||||
|
|
||||||
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
||||||
const maxValue = Math.max(...data.map(d => d.value));
|
const maxValue = Math.max(...data.map(d => d.value));
|
||||||
|
|
@ -25,18 +24,18 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`w-full ${className}`}>
|
<div className={`w-full ${className}`}>
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="text-sm px-4 font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
|
<h3 className="text-lg font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex px-4 flex-col items-center gap-2 space-y-2">
|
<div className="flex flex-col items-center gap-2 space-y-2">
|
||||||
{/* Start Process Line */}
|
{/* Start Process Line */}
|
||||||
<div className="flex items-center w-full gap-10 mt-6 px-4">
|
<div className="flex items-center w-full gap-10 mt-6">
|
||||||
<div className="text-sm font-normal text-[#5F6284] min-w-[max-content]">ابتدا فرآیند</div>
|
<div className="text-lg text-gray-600 min-w-[max-content]">ابتدا فرآیند</div>
|
||||||
<div className="flex items-center w-full gap-4">
|
<div className="flex items-center w-full gap-4">
|
||||||
<div className="w-full h-0.5 bg-gray-600 relative">
|
<div className="w-full h-0.5 bg-gray-600 relative">
|
||||||
<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="text-2xl text-white absolute left-1/2 -translate-x-1/2 top-[-1rem] -translate-y-1/2">۱۰۰%</div>
|
||||||
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
|
<div className="absolute -top-1 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 className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,17 +50,17 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
|
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
|
||||||
<div className="text-sm font-light text-white font-persian cols-start-1 justify-self-start min-w-[max-content] text-center">
|
<div className="text-lg text-white cols-start-1 justify-self-start font-thin min-w-[max-content] text-center">
|
||||||
{item.label}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-10 w-full cols-start-2 justify-center">
|
<div className="flex items-center gap-10 w-full cols-start-2 flex items-center justify-center w-full">
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
|
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
|
||||||
<div
|
<div
|
||||||
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
|
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
|
||||||
style={{ width: `${barWidth}%` ,backgroundColor : `${greenColors[index]}`}}
|
style={{ width: `${barWidth}%` }}
|
||||||
>
|
>
|
||||||
<span className="text-pr-gray text-base font-semibold">
|
<span className="text-[#3F415A] font-semibold">
|
||||||
{item.value.toLocaleString('fa-IR')}
|
{item.value.toLocaleString('fa-IR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,15 +73,15 @@ export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Process Line */}
|
{/* End Process Line */}
|
||||||
<div className="flex items-center w-full gap-10 px-4">
|
<div className="flex items-center w-full gap-10">
|
||||||
<div className="text-sm text-[#5F6284] min-w-[max-content]">انتها فرآیند</div>
|
<div className="text-lg text-gray-600 min-w-[max-content]">انتها فرآیند</div>
|
||||||
<div className="flex items-center w-full gap-4">
|
<div className="flex items-center w-full gap-4">
|
||||||
{(() => {
|
{(() => {
|
||||||
const lastValue = data[data.length - 1]?.value ?? 0;
|
const lastValue = data[data.length - 1]?.value ?? 0;
|
||||||
const percent = toPercent(lastValue);
|
const percent = toPercent(lastValue);
|
||||||
return (
|
return (
|
||||||
<div style={{ width: `${percent}%` }} className={`mx-auto h-0.5 bg-gray-600 relative ${percent === 0 ? "hidden" : ""}`}>
|
<div style={{ width: `${percent}%` }} className={`mx-auto h-0.5 bg-gray-600 relative ${percent === 0 ? "hidden" : ""}`}>
|
||||||
<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="text-2xl text-white absolute left-1/2 -translate-x-1/2 bottom-[-2.5rem] -translate-y-1">{formatNumber(percent)}%</div>
|
||||||
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
|
<div className="absolute -top-1 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 className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { formatNumber } from "~/lib/utils";
|
|
||||||
import { BaseCard } from "./base-card";
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
percentValue?: string | number;
|
|
||||||
valueLabel?: string;
|
|
||||||
percentLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
percentValue,
|
|
||||||
valueLabel = "میلیون ریال",
|
|
||||||
percentLabel = "درصد به کل",
|
|
||||||
}: MetricCardProps) {
|
|
||||||
return (
|
|
||||||
<BaseCard title={title} className="h-full">
|
|
||||||
<div className="flex items-center justify-center flex-col">
|
|
||||||
<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>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +1,26 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
import { cn, formatNumber } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const Progress = React.forwardRef<
|
const Progress = React.forwardRef<
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
>(({ className, value, ...props }, ref) => {
|
>(({ className, value, ...props }, ref) => (
|
||||||
// Dynamic scaling logic based on value ranges
|
<ProgressPrimitive.Root
|
||||||
const getScaledValue = (inputValue: number) => {
|
ref={ref}
|
||||||
const numValue = Number(inputValue);
|
className={cn(
|
||||||
if (numValue <= 1) {
|
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
return numValue * 100;
|
className
|
||||||
}
|
)}
|
||||||
else if (numValue <= 10) {
|
{...props}
|
||||||
return (numValue / 10) * 100;
|
>
|
||||||
} else if (numValue <= 50) {
|
<ProgressPrimitive.Indicator
|
||||||
return (numValue / 50) * 100;
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
else {
|
/>
|
||||||
return numValue
|
</ProgressPrimitive.Root>
|
||||||
}
|
))
|
||||||
};
|
|
||||||
|
|
||||||
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
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
export { Progress }
|
export { Progress }
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||||
containerClassName?: string
|
containerClassName?: string
|
||||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||||
({ className, containerClassName, containerRef, ...props }, ref) => (
|
({ className, containerClassName, ...props }, ref) => (
|
||||||
<div ref={containerRef} className={cn("relative w-full", containerClassName)}>
|
<div className={cn("relative w-full", containerClassName)}>
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("w-full caption-bottom text-sm h-full", className)}
|
className={cn("w-full caption-bottom text-sm h-full", className)}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export function TabsTrigger({
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
isActive
|
isActive
|
||||||
? "bg-pr-gray text-foreground shadow-sm"
|
? "bg-gray-700 text-foreground shadow-sm"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ function TooltipContent({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className={cn("bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]",className)} />
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
|
|
||||||
|
|
||||||
interface TruncatedTextProps {
|
|
||||||
text: string
|
|
||||||
maxWords?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TruncatedText({ text, maxWords = 4 }: TruncatedTextProps) {
|
|
||||||
const words = text.trim().split(/\s+/)
|
|
||||||
console.log(words)
|
|
||||||
const shouldTruncate = words.length > maxWords
|
|
||||||
const displayText = shouldTruncate ? words.slice(0, maxWords).join(" ") + " ..." : text
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className={`${words.length >= 4 ? "cursor-help" : ""} text-foreground`}>
|
|
||||||
{displayText}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{shouldTruncate && (
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
{text}
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
@ -162,24 +162,10 @@ class ApiService {
|
||||||
|
|
||||||
// Innovation process function call wrapper
|
// Innovation process function call wrapper
|
||||||
public async call<T = any>(payload: any) {
|
public async call<T = any>(payload: any) {
|
||||||
//بندر امام
|
|
||||||
const url = "https://inogen-back.pelekan.org/api/call";
|
const url = "https://inogen-back.pelekan.org/api/call";
|
||||||
//آپادانا
|
|
||||||
const url = "https://APADANA-IATM-back.pelekan.org/api/call";
|
|
||||||
//نوری
|
|
||||||
const url = "https://NOPC-IATM-back.pelekan.org/api/call";
|
|
||||||
return this.postAbsolute<T>(url, payload);
|
return this.postAbsolute<T>(url, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
//بندر امام
|
|
||||||
// import.meta.env.VITE_API_URL || "https://inogen-bpms-back.pelekan.org/api";
|
|
||||||
//آپادانا
|
|
||||||
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
|
|
||||||
//نوری
|
|
||||||
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
|
|
||||||
|
|
||||||
// GET request
|
// GET request
|
||||||
public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> {
|
public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(endpoint, {
|
||||||
|
|
|
||||||
101
app/lib/utils.ts
101
app/lib/utils.ts
|
|
@ -1,7 +1,6 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import EventEmitter from "events";
|
|
||||||
import moment from "moment-jalaali";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import moment from "moment-jalaali";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|
@ -23,100 +22,10 @@ export const formatCurrency = (amount: string | number) => {
|
||||||
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
|
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* محاسبه دامنه nice numbers برای محور Y نمودارها
|
|
||||||
* @param values آرایه از مقادیر دادهها
|
|
||||||
* @param minValue حداقل مقدار (پیشفرض: 0 برای دادههای درصدی)
|
|
||||||
* @param marginPercent درصد حاشیه اضافی (پیشفرض: 5%)
|
|
||||||
* @returns شیء شامل حداکثر nice، فاصله tick ها، و آرایه tick ها
|
|
||||||
*/
|
|
||||||
export function calculateNiceRange(
|
|
||||||
values: number[],
|
|
||||||
minValue: number = 0,
|
|
||||||
marginPercent: number = 5
|
|
||||||
): {
|
|
||||||
niceMax: number;
|
|
||||||
tickInterval: number;
|
|
||||||
ticks: number[];
|
|
||||||
} {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return { niceMax: 100, tickInterval: 20, ticks: [0, 20, 40, 60, 80, 100] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// پیدا کردن حداکثر مقدار در دادهها
|
|
||||||
const dataMax = Math.max(...values);
|
|
||||||
|
|
||||||
// اگر همه مقادیر صفر یا منفی هستند
|
export const handleDataValue = (val: any): any => {
|
||||||
if (dataMax <= 0) {
|
moment.loadPersian({ usePersianDigits: true });
|
||||||
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 (val == null) return val;
|
||||||
if (
|
if (
|
||||||
typeof val === "string" &&
|
typeof val === "string" &&
|
||||||
|
|
@ -131,6 +40,4 @@ export const handleDataValue = (val: any): any => {
|
||||||
return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
|
return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const EventBus = new EventEmitter();
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,27 @@
|
||||||
import moment from "moment-jalaali";
|
import type { Route } from "./+types/ecosystem";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ProtectedRoute } from "~/components/auth/protected-route";
|
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||||
import { DashboardLayout } from "~/components/dashboard/layout";
|
import { DashboardLayout } from "~/components/dashboard/layout";
|
||||||
import { InfoPanel } from "~/components/ecosystem/info-panel";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { NetworkGraph } from "~/components/ecosystem/network-graph";
|
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
|
import { NetworkGraph } from "~/components/ecosystem/network-graph";
|
||||||
|
import { InfoPanel } from "~/components/ecosystem/info-panel";
|
||||||
import { useAuth } from "~/contexts/auth-context";
|
import { useAuth } from "~/contexts/auth-context";
|
||||||
import type { Route } from "./+types/ecosystem";
|
import moment from "moment-jalaali";
|
||||||
|
|
||||||
// Get API base URL at module level to avoid process.env access in browser
|
// Get API base URL at module level to avoid process.env access in browser
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
//بندر امام
|
import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
|
||||||
// import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
|
|
||||||
//آپادانا
|
|
||||||
import.meta.env.VITE_API_URL || "https://APADANA-IATM-back.pelekan.org/api";
|
|
||||||
//نوری
|
|
||||||
// import.meta.env.VITE_API_URL || "https://NOPC-IATM-back.pelekan.org/api";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Import the CompanyDetails type
|
// Import the CompanyDetails type
|
||||||
import { Hexagon } from "lucide-react";
|
|
||||||
import type { CompanyDetails } from "~/components/ecosystem/network-graph";
|
import type { CompanyDetails } from "~/components/ecosystem/network-graph";
|
||||||
|
import { formatNumber } from "~/lib/utils";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
|
|
@ -61,20 +55,10 @@ function handleValue(val: any): any {
|
||||||
export default function EcosystemPage() {
|
export default function EcosystemPage() {
|
||||||
const [selectedCompany, setSelectedCompany] =
|
const [selectedCompany, setSelectedCompany] =
|
||||||
React.useState<CompanyDetails | null>(null);
|
React.useState<CompanyDetails | null>(null);
|
||||||
const [isDialogLoading, setIsDialogLoading] = React.useState(false);
|
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setSelectedCompany(null);
|
setSelectedCompany(null);
|
||||||
setIsDialogLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeClick = (company: CompanyDetails) => {
|
|
||||||
setSelectedCompany(company);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadingChange = (loading: boolean) => {
|
|
||||||
setIsDialogLoading(loading);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Construct image URL
|
// Construct image URL
|
||||||
|
|
@ -85,19 +69,16 @@ export default function EcosystemPage() {
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requireAuth={true}>
|
<ProtectedRoute requireAuth={true}>
|
||||||
<DashboardLayout title="زیست بوم فناوری">
|
<DashboardLayout title="زیست بوم فناوری">
|
||||||
<div>
|
<div className="p-4 lg:p-6">
|
||||||
<div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4">
|
<div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4">
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<InfoPanel selectedCompany={selectedCompany} />
|
<InfoPanel selectedCompany={selectedCompany} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-8 h-full">
|
<div className="lg:col-span-8 h-full">
|
||||||
<Card className="h-full overflow-hidden bg-transparent border-[#3F415A]">
|
<Card className="h-full overflow-hidden">
|
||||||
<CardContent className="p-0 h-full bg-transparent">
|
<CardContent className="p-0 h-full">
|
||||||
<NetworkGraph
|
<NetworkGraph onNodeClick={setSelectedCompany} />
|
||||||
onNodeClick={handleNodeClick}
|
|
||||||
onLoadingChange={handleLoadingChange}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,146 +90,104 @@ export default function EcosystemPage() {
|
||||||
open={!!selectedCompany}
|
open={!!selectedCompany}
|
||||||
onOpenChange={(open) => !open && closeDialog()}
|
onOpenChange={(open) => !open && closeDialog()}
|
||||||
>
|
>
|
||||||
<DialogContent className="font-persian max-w-6xl min-h-max bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)]">
|
<DialogContent className="font-persian max-w-6xl max-h-[75vh] overflow-y-auto bg-[linear-gradient(to_bottom_left,#464861,20%,#111628)]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-right border-b-2 border-gray-600 pt-2 pb-4 mr-4 text-sm font-semibold">
|
<DialogTitle className="text-right border-b-2 border-gray-600 py-2 mr-4 text-xl">
|
||||||
معرفی
|
معرفی
|
||||||
<span> {selectedCompany?.category}</span>
|
<span> {selectedCompany?.category}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center text-green-400"></DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isDialogLoading ? (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-6">
|
{/* Right Column - Description */}
|
||||||
{/* Right Column - Loading Skeleton */}
|
<div className="space-y-4 p-6 border-l-2 border-gray-600">
|
||||||
<div className="space-y-4 p-6 border-l-2 border-gray-600">
|
{/* Company Image */}
|
||||||
{/* Company Image & Title Skeleton */}
|
<div className="flex justify-between px-10 items-center text-3xl font-bold mb-4">
|
||||||
<div className="flex justify-between px-10 items-center mb-4">
|
{selectedCompany?.label || ""}
|
||||||
<div className="h-8 bg-gray-600 rounded animate-pulse w-48"></div>
|
{selectedCompany?.stageid && token?.accessToken ? (
|
||||||
<div className="w-12 h-12 bg-gray-600 rounded-2xl animate-pulse"></div>
|
<img
|
||||||
</div>
|
src={getImageUrl(selectedCompany.stageid)}
|
||||||
{/* Description Skeleton */}
|
alt={selectedCompany?.label || ""}
|
||||||
<div className="p-4 rounded-lg space-y-2">
|
className="w-14 h-14 object-cover rounded-2xl"
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-full"></div>
|
onError={(e) => {
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-5/6"></div>
|
// Hide image and show fallback on error
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-4/6"></div>
|
e.currentTarget.style.display = "none";
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-3/6"></div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Left Column - Loading Skeleton */}
|
{selectedCompany?.description ? (
|
||||||
<div className="space-y-2">
|
<div className="p-4 rounded-lg">
|
||||||
<div className="h-6 bg-gray-600 rounded animate-pulse w-32"></div>
|
<p className="font-persian leading-relaxed">
|
||||||
<div className="space-y-3 px-2">
|
{selectedCompany.description}
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
</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) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex justify-between items-center rounded-lg"
|
className="flex justify-between items-center rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<span className="font-persian font-light">
|
||||||
<div className="h-4 w-4 bg-gray-600 rounded animate-pulse"></div>
|
{field.N}:
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-24"></div>
|
</span>
|
||||||
</div>
|
<span className="font-persian font-light text-right">
|
||||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-20"></div>
|
{handleValue(field.V)}
|
||||||
|
{field.U && <span className="mr-1">({field.U})</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<div className="text-gray-500 font-persian text-sm">
|
||||||
) : (
|
اطلاعات تکمیلی در دسترس نیست
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { ManageIdeasTechPage } from "~/components/dashboard/project-management/m
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
{ title: "مدیریت فناوری و ایده ها" },
|
{ title: "مدیریت فنواری و ایده ها" },
|
||||||
{ name: "description", content: "مدیریت پروژههای فناوری و نوآوری" },
|
{ name: "description", content: "مدیریت پروژههای فناوری و نوآوری" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export interface CalendarDate {
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
sinceMonth?: string;
|
|
||||||
untilMonth?: string;
|
|
||||||
}
|
|
||||||
686
package-lock.json
generated
686
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -26,7 +26,6 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
|
@ -36,13 +35,11 @@
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-router": "^7.7.0",
|
"react-router": "^7.7.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1"
|
||||||
"xlsx-js-style": "^1.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.7.0",
|
"@react-router/dev": "^7.7.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/file-saver": "^2.0.7",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
|
|
||||||
1546
pnpm-lock.yaml
1546
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -1,426 +0,0 @@
|
||||||
<!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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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 doesn’t 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">
|
|
||||||
۳٫۷۷۷ ÷ ۱٫۶۱۸ = <span class="text-regular text-underline">۲٫۳۳۵</span>
|
|
||||||
<br>
|
|
||||||
۶٫۱۱۲ ÷ ۱٫۶۱۸ = <span class="text-regular text-underline">۳٫۷۷۷</span>
|
|
||||||
<br>
|
|
||||||
۹٫۸۸۹ ÷ ۱٫۶۱۸ = <span class="text-regular text-underline">۶٫۱۱۲</span>
|
|
||||||
<br>
|
|
||||||
۱۶ ÷ ۱٫۶۱۸ = <span class="text-regular text-underline">۹٫۸۸۹</span>
|
|
||||||
<br>
|
|
||||||
.......................................
|
|
||||||
<br>
|
|
||||||
۱۶ × ۱٫۶۱۸ = <span class="text-regular text-underline">۲۵٫۸۸۸</span>
|
|
||||||
<br>
|
|
||||||
۲۵٫۸۸۸ × ۱٫۶۱۸ = <span class="text-regular text-underline">۴۱٫۸۸۷</span>
|
|
||||||
<br>
|
|
||||||
۴۱٫۸۸۷ × ۱٫۶۱۸ = <span class="text-regular text-underline">۶۷٫۷۷۳</span>
|
|
||||||
<br>
|
|
||||||
۶۷٫۷۷۳ × ۱٫۶۱۸ = <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>
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
/**
|
|
||||||
*
|
|
||||||
* 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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,463 +0,0 @@
|
||||||
<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>
|
|
||||||
 
|
|
||||||
<a href="https://caniuse.com/#feat=woff">woff</a>
|
|
||||||
<a href="https://caniuse.com/#feat=woff2">woff2</a>
|
|
||||||
 
|
|
||||||
<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> <span class="sampletext" id="p08"></span></p>
|
|
||||||
<p><span class="label">09</span> <span class="sampletext" id="p09"></span></p>
|
|
||||||
<p><span class="label">10</span> <span class="sampletext" id="p10"></span></p>
|
|
||||||
<p><span class="label">11</span> <span class="sampletext" id="p11"></span></p>
|
|
||||||
<p><span class="label">12</span> <span class="sampletext" id="p12"></span></p>
|
|
||||||
<p><span class="label">13</span> <span class="sampletext" id="p13"></span></p>
|
|
||||||
<p><span class="label">14</span> <span class="sampletext" id="p14"></span></p>
|
|
||||||
<p><span class="label">15</span> <span class="sampletext" id="p15"></span></p>
|
|
||||||
<p><span class="label">16</span> <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.
|
|
@ -1,243 +0,0 @@
|
||||||
@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.
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ssr: false,
|
// Config options...
|
||||||
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
green: '#3AEA83',
|
|
||||||
blue: '#69C8EA',
|
|
||||||
red: '#F76276',
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user