add new page #9
File diff suppressed because it is too large
Load Diff
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
app/components/ui/funnel-chart.tsx
Normal file
95
app/components/ui/funnel-chart.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React from "react";
|
||||||
|
import { formatNumber } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface FunnelData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
percentage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelChartProps {
|
||||||
|
data: FunnelData[];
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunnelChart({ data, title, className = "" }: FunnelChartProps) {
|
||||||
|
const maxValue = Math.max(...data.map(d => d.value));
|
||||||
|
const toPercent = (value: number) => {
|
||||||
|
if (!maxValue || maxValue <= 0) return 0;
|
||||||
|
return Math.round((value / maxValue) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full ${className}`}>
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 py-2 text-right border-b-2 border-gray-400/20">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 space-y-2">
|
||||||
|
{/* Start Process Line */}
|
||||||
|
<div className="flex items-center w-full gap-10 mt-6">
|
||||||
|
<div className="text-lg text-gray-600 min-w-[max-content]">ابتدا فرآیند</div>
|
||||||
|
<div className="flex items-center w-full gap-4">
|
||||||
|
<div className="w-full h-0.5 bg-gray-600 relative">
|
||||||
|
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 top-[-1rem] -translate-y-1/2">۱۰۰%</div>
|
||||||
|
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
|
||||||
|
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Funnel Bars */}
|
||||||
|
<div className="flex flex-col items-center space-y-1 gap-2 w-full max-w-md">
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const widthPercentage = toPercent(item.value);
|
||||||
|
const barWidth = Math.max(20, widthPercentage); // Minimum 20% width
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="grid grid-cols-[6rem_1fr] gap-2 w-full">
|
||||||
|
<div className="text-lg text-white cols-start-1 justify-self-start font-thin min-w-[max-content] text-center">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-10 w-full cols-start-2 flex items-center justify-center w-full">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
|
||||||
|
<div
|
||||||
|
className="bg-[#3BC47A] h-8 rounded-2xl flex items-center justify-center text-lg relative"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
>
|
||||||
|
<span className="text-[#3F415A] font-semibold">
|
||||||
|
{item.value.toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: `${(100 - barWidth) / 2}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Process Line */}
|
||||||
|
<div className="flex items-center w-full gap-10">
|
||||||
|
<div className="text-lg text-gray-600 min-w-[max-content]">انتها فرآیند</div>
|
||||||
|
<div className="flex items-center w-full gap-4">
|
||||||
|
{(() => {
|
||||||
|
const lastValue = data[data.length - 1]?.value ?? 0;
|
||||||
|
const percent = toPercent(lastValue);
|
||||||
|
return (
|
||||||
|
<div style={{ width: `${percent}%` }} className={`mx-auto h-0.5 bg-gray-600 relative ${percent === 0 ? "hidden" : ""}`}>
|
||||||
|
<div className="text-2xl text-white absolute left-1/2 -translate-x-1/2 bottom-[-2.5rem] -translate-y-1">{formatNumber(percent)}%</div>
|
||||||
|
<div className="absolute -top-1 left-0 w-1 h-3 bg-gray-600"></div>
|
||||||
|
<div className="absolute -top-1 right-0 w-1 h-3 bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,10 @@ export default [
|
||||||
route(
|
route(
|
||||||
"dashboard/innovation-basket/process-innovation",
|
"dashboard/innovation-basket/process-innovation",
|
||||||
"routes/innovation-basket.process-innovation.tsx"
|
"routes/innovation-basket.process-innovation.tsx"
|
||||||
|
),
|
||||||
|
route(
|
||||||
|
"dashboard/innovation-basket/product-innovation",
|
||||||
|
"routes/innovation-basket.product-innovation.tsx"
|
||||||
),
|
),
|
||||||
route(
|
route(
|
||||||
"dashboard/innovation-basket/green-innovation",
|
"dashboard/innovation-basket/green-innovation",
|
||||||
|
|
|
||||||
17
app/routes/innovation-basket.product-innovation.tsx
Normal file
17
app/routes/innovation-basket.product-innovation.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { ProductInnovationPage } from "~/components/dashboard/project-management/product-innovation-page";
|
||||||
|
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return [
|
||||||
|
{ title: "نوآوری محصول - سیستم مدیریت فناوری و نوآوری" },
|
||||||
|
{ name: "description", content: "مدیریت پروژههای نوآوری محصول" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductInnovation() {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute requireAuth={true}>
|
||||||
|
<ProductInnovationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user