diff --git a/app/components/dashboard/project-management/digital-innovation-page.tsx b/app/components/dashboard/project-management/digital-innovation-page.tsx index c55a190..1713261 100644 --- a/app/components/dashboard/project-management/digital-innovation-page.tsx +++ b/app/components/dashboard/project-management/digital-innovation-page.tsx @@ -441,13 +441,38 @@ export function DigitalInnovationPage() { innovation_digital_function: {}, }); - let payload: DigitalInnovationMetrics = raw?.data; - if (typeof payload === "string") { + + + // let payload: DigitalInnovationMetrics = raw?.data; + // console.log("*-*-*-*" +payload); + // if (typeof payload === "string") { + // try { + // payload = JSON.parse(payload).innovation_digital_function; + + // } catch {} + // } + + let payload: DigitalInnovationMetrics | null = null; + + if (raw?.data) { try { - payload = JSON.parse(payload); - } catch {} + // مرحله اول: data رو از string به object تبدیل کن + const parsedData = JSON.parse(raw.data); + + // مرحله دوم: innovation_digital_function رو که خودش string هست parse کن + const arr = JSON.parse(parsedData.innovation_digital_function); + + // مرحله سوم: اولین خانه آرایه رو بردار + if (Array.isArray(arr) && arr.length > 0) { + payload = arr[0]; + } + } catch (err) { + console.error("Error parsing API response:", err); + } } + + const parseNum = (v: unknown): number => { if (v == null) return 0; if (typeof v === "number") return v; diff --git a/app/components/dashboard/project-management/green-innovation-page.tsx b/app/components/dashboard/project-management/green-innovation-page.tsx index 6601799..116a4bb 100644 --- a/app/components/dashboard/project-management/green-innovation-page.tsx +++ b/app/components/dashboard/project-management/green-innovation-page.tsx @@ -1,4 +1,4 @@ -import moment from "moment-jalaali"; +// import moment from "moment-jalaali"; import { useCallback, useEffect, useRef, useState } from "react"; import { Bar, @@ -47,7 +47,7 @@ import apiService from "~/lib/api"; import { formatCurrency } from "~/lib/utils"; import DashboardLayout from "../layout"; -moment.loadPersian({ usePersianDigits: true }); +// moment.loadPersian({ usePersianDigits: true }); interface GreenInnovationData { WorkflowID: string; approved_budget: string; diff --git a/app/components/dashboard/project-management/innovation-built-inside-page.tsx b/app/components/dashboard/project-management/innovation-built-inside-page.tsx index 4780b99..ddf6848 100644 --- a/app/components/dashboard/project-management/innovation-built-inside-page.tsx +++ b/app/components/dashboard/project-management/innovation-built-inside-page.tsx @@ -1,4 +1,3 @@ -import moment from "moment-jalaali"; import { useCallback, useEffect, useRef, useState } from "react"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; @@ -43,8 +42,6 @@ import apiService from "~/lib/api"; import { formatCurrency } from "~/lib/utils"; import DashboardLayout from "../layout"; -moment.loadPersian({ usePersianDigits: true }); - interface innovationBuiltInDate { WorkflowID: number; approved_budget: string; @@ -194,9 +191,8 @@ export function InnovationBuiltInsidePage() { direction: "asc", }); const [tblAvarage, setTblAvarage] = useState(0); - const [selectedProjects, setSelectedProjects] = useState< - Set - >(new Set()); + const [selectedProjects, setSelectedProjects] = + useState>(); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [selectedProjectDetails, setSelectedProjectDetails] = useState(); @@ -426,7 +422,6 @@ export function InnovationBuiltInsidePage() { useEffect(() => { fetchProjects(true); - fetchStats(); }, [sortConfig]); useEffect(() => { @@ -486,7 +481,7 @@ export function InnovationBuiltInsidePage() { const raw = await apiService.call({ innovation_construction_inside_function: { project_ids: - selectedProjects.size > 0 + selectedProjects && selectedProjects?.size > 0 ? Array.from(selectedProjects).join(" , ") : "", }, @@ -622,7 +617,7 @@ export function InnovationBuiltInsidePage() { case "select": return ( handleSelectProject(item?.project_id)} className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600 cursor-pointer" /> @@ -1039,7 +1034,7 @@ export function InnovationBuiltInsidePage() { {/* Project Details Dialog */} - + شرح پروژه @@ -1192,12 +1187,12 @@ export function InnovationBuiltInsidePage() { ) : (
-
- +
+ شاخص مقایسه با نمونه خارجی - نمونه داخلی - نمونه خارجی + نمونه داخلی + نمونه خارجی
{selectedProjectDetails?.technology_params?.map( @@ -1268,7 +1263,7 @@ export function InnovationBuiltInsidePage() { + {/* Year labels */} +
+ ۱۴۰۷ + ۱۴۰۶ + ۱۴۰۵ + ۱۴۰۴ +
+ + {/* Timeline bar */} +
+ {stages.map((stage, index) => ( +
+ + +
+ {stage} +
+
+
+
+ ))} + + {/* Vertical line showing current position */} +
+
وضعیت فعلی
+
+
+ ); +} + + + +export function ProductInnovationPage() { + const [showPopup, setShowPopup] = useState(false); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(20); + const [hasMore, setHasMore] = useState(true); + const [totalCount, setTotalCount] = useState(0); + const [statsLoading, setStatsLoading] = useState(false); + const [stats, setStats] = useState({ + new_products_revenue_share: 0, + new_products_revenue_share_percent: 0, + new_products_export: 0, + import_impact: 0, + all_funnel: 0, + successful_sample_funnel: 0, + successful_products_funnel: 0, + successful_improvement_or_change_funnel: 0, + new_product_funnel: 0, + count_innovation_construction_inside_projects: 0, + average_project_score: 0, + }); + const [sortConfig, setSortConfig] = useState({ + field: "start_date", + direction: "asc", + }); + const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); + const [selectedProjectDetails, setSelectedProjectDetails] = + useState(null); + const [popupStats, setPopupStats] = useState({ + new_products_export: 0, + new_products_export_percent: 0, + import_impact: 0, + import_impact_percent: 0, + }); + const [exportChartData, setExportChartData] = useState([]); + const [allExportData, setAllExportData] = useState([]); + const [popupLoading, setPopupLoading] = useState(false); + + const [stateCard, setStateCard] = useState({ + revenueNewProducts: { + id: "revenueNewProducts", + title: "سهم از درآمد برای محصولات جدید", + value: "0", + description: "میلیون ریال", + descriptionPercent: "درصد به کل درآمد", + color: "text-[#3AEA83]", + percent : "0" + }, + newProductExports: { + id: "newProductExports", + title: "صادرات محصول جدید", + value: "0", + description: "میلیون ریال", + color: "text-[#3AEA83]", + }, + impactOnImports: { + id: "impactOnImports", + title: "تأثیر در واردات", + value: "0", + description: "میلیون ریال", + color: "text-[#F76276]", + }, + }); + + const observerRef = useRef(null); + const fetchingRef = useRef(false); + + + + const handleProjectDetails = async (project: ProductInnovationData) => { + setSelectedProjectDetails(project); + console.log(project) + setDetailsDialogOpen(true); + await fetchPopupData(project); + }; + + const fetchPopupData = async (project: ProductInnovationData) => { + try { + setPopupLoading(true); + + // Fetch popup stats + const statsResponse = await apiService.call({ + innovation_product_popup_function1: { + project_id: project.project_id + } + }); + + if (statsResponse.state === 0) { + const statsData = JSON.parse(statsResponse.data); + if (statsData.innovation_product_popup_function1 && statsData.innovation_product_popup_function1[0]) { + setPopupStats(JSON.parse(statsData.innovation_product_popup_function1)[0]); + } + } + + // Fetch export chart data + const chartResponse = await apiService.select({ + ProcessName: "export_product_innovation", + OutputFields: [ + "product_title", + "full_season", + "sum(export_revenue)" + ], + GroupBy: ["product_title", "full_season"] + }); + if (chartResponse.state === 0) { + const chartData = JSON.parse(chartResponse.data); + if (Array.isArray(chartData)) { + // Set all data for line chart + + // Filter data for the selected project (bar chart) + const filteredData = chartData.filter(item => + item.product_title === project?.title + ); + setAllExportData(chartData); + setExportChartData(filteredData); + } + } + + } catch (error) { + console.error("Error fetching popup data:", error); + } finally { + setPopupLoading(false); + } + }; + + const loadMore = useCallback(() => { + if (!loadingMore && hasMore && !loading) { + setCurrentPage((prev) => prev + 1); + } + }, [loadingMore, hasMore, loading]); + + const fetchProjects = async (reset = false) => { + if (fetchingRef.current) { + return; + } + + try { + fetchingRef.current = true; + + if (reset) { + setLoading(true); + setCurrentPage(1); + } else { + setLoadingMore(true); + } + + const pageToFetch = reset ? 1 : currentPage; + + const response = await apiService.select({ + ProcessName: "project", + OutputFields: [ + "project_id", + "project_no", + "title", + "project_status", + "project_rating", + "project_description", + "developed_technology_type", + "obtained_standard_title", + "knowledge_based_certificate_obtained", + "knowledge_based_certificate_number", + "certificate_obtain_date", + "issuing_authority", + ], + Sorts: [["start_date", "asc"]], + Conditions: [["type_of_innovation", "=", "نوآوری در محصول"]], + Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, + }); + + if (response.state === 0) { + const dataString = response.data; + if (dataString && typeof dataString === "string") { + try { + const parsedData = JSON.parse(dataString); + if (Array.isArray(parsedData)) { + if (reset) { + setProjects(parsedData); + setTotalCount(parsedData.length); + } else { + setProjects((prev) => [...prev, ...parsedData]); + setTotalCount((prev) => prev + parsedData.length); + } + setHasMore(parsedData.length === pageSize); + } else { + if (reset) { + setProjects([]); + setTotalCount(0); + } + setHasMore(false); + } + } catch (parseError) { + console.error("Error parsing project data:", parseError); + if (reset) { + setProjects([]); + setTotalCount(0); + } + setHasMore(false); + } + } else { + if (reset) { + setProjects([]); + setTotalCount(0); + } + setHasMore(false); + } + } else { + toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها"); + if (reset) { + setProjects([]); + setTotalCount(0); + } + setHasMore(false); + } + } catch (error) { + console.error("Error fetching projects:", error); + toast.error("خطا در دریافت اطلاعات پروژه‌ها"); + if (reset) { + setProjects([]); + setTotalCount(0); + } + setHasMore(false); + } finally { + setLoading(false); + setLoadingMore(false); + fetchingRef.current = false; + } + }; + + const fetchStats = async () => { + try { + setStatsLoading(true); + const raw = await apiService.call({ + innovation_product_function: {}, + }); + + let payload: any = JSON.parse(raw?.data); + const parseNum = (v: unknown): any => { + const convertNumber = typeof v === "number" ? Math.max(0, v) : 0; + if (v == null) return 0; + if (typeof v === "number") return convertNumber; + if (typeof v === "string") { + const cleaned = v.replace(/,/g, "").trim(); + const n = parseFloat(cleaned); + return isNaN(n) ? 0 : convertNumber; + } + return 0; + }; + + const data: Array = JSON.parse( + payload?.innovation_product_function + ); + const stats = data[0]; + const normalized: ProductInnovationStats = { + new_products_revenue_share: parseNum(stats?.new_products_revenue_share), + new_products_revenue_share_percent: parseNum(stats?.new_products_revenue_share_percent), + import_impact: parseNum(stats?.import_impact), + new_products_export: parseNum(stats?.new_products_export), + all_funnel: parseNum(stats?.all_funnel), + successful_sample_funnel: parseNum(stats?.successful_sample_funnel), + successful_products_funnel: parseNum(stats?.successful_products_funnel), + successful_improvement_or_change_funnel: parseNum(stats?.successful_improvement_or_change_funnel), + new_product_funnel: parseNum(stats?.new_product_funnel), + count_innovation_construction_inside_projects: parseNum(stats?.count_innovation_construction_inside_projects), + average_project_score: parseNum(stats?.average_project_score), + }; + + setStateCard((prev) => ({ + ...prev, + revenueNewProducts: { + ...prev.revenueNewProducts, + value: formatNumber(normalized.new_products_revenue_share), + percent: formatNumber(normalized.new_products_revenue_share_percent), + }, + impactOnImports: { + ...prev.impactOnImports, + value: formatNumber(normalized.import_impact), + }, + newProductExports: { + ...prev.newProductExports, + value: formatNumber(normalized.new_products_export), + }, + })); + + setStats(normalized); + } catch (error) { + console.error("Error fetching stats:", error); + } finally { + setStatsLoading(false); + } + }; + + useEffect(() => { + fetchProjects(true); + }, [sortConfig]); + + useEffect(() => { + fetchStats(); + }, []); + + useEffect(() => { + if (currentPage > 1) { + fetchProjects(false); + } + }, [currentPage]); + + useEffect(() => { + const scrollContainer = document.querySelector(".overflow-auto"); + + const handleScroll = () => { + if (!scrollContainer || !hasMore || loadingMore) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + + if (scrollPercentage >= 0.9) { + loadMore(); + } + }; + + if (scrollContainer) { + scrollContainer.addEventListener("scroll", handleScroll); + } + + return () => { + if (scrollContainer) { + scrollContainer.removeEventListener("scroll", handleScroll); + } + }; + }, [loadMore, hasMore, loadingMore]); + + const handleSort = (field: string) => { + fetchingRef.current = false; + setSortConfig((prev) => ({ + field, + direction: + prev.field === field && prev.direction === "asc" ? "desc" : "asc", + })); + setCurrentPage(1); + setProjects([]); + setHasMore(true); + }; + + + const formatCurrency = (amount: string | number) => { + if (!amount) return "0 ریال"; + const numericAmount = + typeof amount === "string" + ? parseFloat(amount.replace(/,/g, "")) + : amount; + if (isNaN(numericAmount)) return "0 ریال"; + return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; + }; + + // Transform data for line chart + const transformDataForLineChart = (data: any[]) => { + const seasons = [...new Set(data.map(item => item.full_season))]; + const products = [...new Set(data.map(item => item.product_title))]; + return seasons.map(season => { + const seasonData: any = { season }; + products.forEach(product => { + const productData = data.find(item => + item.product_title === product && item.full_season === season + ); + seasonData[product] = productData?.export_revenue_sum > 0 && productData ? Math.round(productData?.export_revenue_sum) : 0; + }); + return seasonData; + }); + }; + + const getRatingColor = (rating: string | number) => { + const numRating = typeof rating === "string" ? parseInt(rating) : rating; + if (numRating >= 150) return "text-emerald-400"; + if (numRating >= 100) return "text-blue-400"; + return "text-red-400"; + }; + + const statusColor = (status: projectStatus): any => { + let el = null; + switch (status) { + case projectStatus.contract: + el = "teal"; + break; + case projectStatus.finish: + el = "info"; + break; + case projectStatus.stop: + el = "warning"; + break; + case projectStatus.inprogress: + el = "teal"; + break; + case projectStatus.mafasa: + el = "destructive"; + break; + case projectStatus.propozal: + el = "info"; + break; + case projectStatus.notstarted: + el = "secondary"; + break; + case projectStatus.delayed: + el = "destructive"; + break; + } + return el; + }; + + const renderCellContent = (item: ProductInnovationData, column: any) => { + const value = item[column.key as keyof ProductInnovationData]; + + switch (column.key) { + case "select": + return null; + case "details": + return ( + + ); + case "project_no": + return ( + + {String(value)} + + ); + case "title": + return {String(value)}; + case "project_status": + return ( +
+ + {String(value)} +
+ ); + case "project_rating": + return ( + + {formatNumber(String(value))} + + ); + default: + return {String(value) || "-"}; + } + }; + + const seasonOrder = ["بهار", "تابستان", "پاییز", "زمستان"]; + const sortedBarData = exportChartData + .sort((a, b) => { + const getSeasonIndex = (s: string) => { + const [seasonName, year] = s.split(" "); + return parseInt(year) * 4 + seasonOrder.indexOf(seasonName); + }; + return getSeasonIndex(a.full_season) - getSeasonIndex(b.full_season); + }) + .map((item) => ({ + label: item.full_season, + value: item.export_revenue_sum < 0 ? 0 : Math.round(item.export_revenue_sum) , + })); + + return ( + +
+ {/* Stats Cards */} +
+
+ {/* Stats Grid */} +
+ {loading || statsLoading + ? // Loading skeleton for stats cards - matching new design + Array.from({ length: 3 }).map((_, index) => ( + + +
+
+
+
+
+
+
+
+
+
+
+
+ + + )) + : Object.entries(stateCard).map(([key, card], index) => ( + + +
+
+

+ {card.title} +

+
+
+
+
+

+ {card.value} + +

+

+ {card.description} +

+
+ {card?.percent && /} + {card?.percent &&
+

+ {card?.percent} + +

+

+ {card.descriptionPercent} +

+
} +
+
+
+ ))} +
+
+ + {/* Funnel Chart */} + + + + + +
+ + {/* Data Table */} + + +
+ + + + {columns.map((column) => ( + + {column.sortable ? ( + + ) : ( + column.label + )} + + ))} + + + + {loading ? ( + // Skeleton loading rows (compact) + Array.from({ length: 10 }).map((_, index) => ( + + {columns.map((column) => ( + +
+
+
+
+ + ))} + + )) + ) : projects.length === 0 ? ( + + + + هیچ پروژه‌ای یافت نشد + + + + ) : ( + projects.map((project, index) => ( + + {columns.map((column) => ( + + {renderCellContent(project, column)} + + ))} + + )) + )} + +
+
+ + {/* Infinite scroll trigger */} +
+ {loadingMore && ( +
+
+ + +
+
+ )} +
+
+ + {/* Footer */} +
+
+
+
+ کل پروژه ها :{formatNumber(stats?.count_innovation_construction_inside_projects)} +
+
+ +
+
+ + + + +
+
+
میانگین :‌
+
+ {formatNumber( + ((stats.average_project_score ?? 0) as number).toFixed?.(1) ?? 0 + )} +
+
+
+
+
+
+
+ + {/* Project Details Dialog */} + + + + + شرح پروژه + + + +
+ {/* right Column - Stats Cards and Details */} +
+ {/* Stats Cards */} +
+

{selectedProjectDetails?.title}

+

{selectedProjectDetails?.project_description}

+
+ + + {/* Technical Knowledge */} +
+

دانش فنی محصول جدید

+
+
+ توسعه درونزا + + +
+ +
+ همکاری فناورانه + + +
+ +
+ سایر + +
+
+
+ + {/* Standards */} +
+

+ استانداردهای ملی و بین‌المللی اخذ شده +

+ + {selectedProjectDetails?.obtained_standard_title && selectedProjectDetails?.obtained_standard_title.length > 0 ? ( +
+ {(Array.isArray(selectedProjectDetails?.obtained_standard_title) + ? selectedProjectDetails?.obtained_standard_title + : [selectedProjectDetails?.obtained_standard_title] + ).map((standard, index) => ( +
+
+ {standard} +
+ ))} +
+ ) : ( +

+ هیچ استانداردی ثبت نشده است. +

+ )} +
+ + {/* Knowledge-based Certificate Button */} +
+ {selectedProjectDetails?.knowledge_based_certificate_obtained === "خیر" ? ( +
+ +
+ ) : ( + + + + + + + + +
+

+ شماره گواهی: + {selectedProjectDetails?.knowledge_based_certificate_number || + "—"} +

+

+ تاریخ اخذ: + {handleDataValue(selectedProjectDetails?.certificate_obtain_date) || "—"} +

+

+ مرجع صادرکننده: + {selectedProjectDetails?.issuing_authority || "—"} +

+
+
+
+
+
+ )} +
+
+ + {/* Left Column - Project Description and Charts */} + {popupLoading ? ( +
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ ) : ( +
+ {/* Project Description */} +
+ + +
+
+

+ میزان صادارت محصول جدید +

+ +
+ +
+

+ {formatNumber(Math.round(popupStats?.new_products_export > 0 ? popupStats?.new_products_export : 0)) || formatNumber(0)} +

+

+ میلیون ریال +

+
+ / +
+

+ {formatNumber(Math.round(popupStats?.new_products_export_percent > 0 ? popupStats?.new_products_export_percent : 0)) || formatNumber(0)}% +

+

+ درصد به کل صادرات +

+
+
+
+
+ + + +
+
+

+ تاثیر در واردات +

+ +
+ +
+

+ {formatNumber(Math.round(popupStats?.import_impact > 0 ? popupStats?.import_impact : 0)) || formatNumber(0)} +

+

+ میلیون ریال +

+
+ / +
+

+ {formatNumber(Math.round(popupStats?.import_impact_percent > 0 ? popupStats?.import_impact_percent : 0)) || formatNumber(0)}% +

+

+ درصد صرفه جویی +

+
+
+
+
+ + + +
+ + {/* Export Revenue Bar Chart */} +
+

ظرفیت صادر شده

+
+ {popupLoading ? ( +
+
در حال بارگذاری...
+
+ ) : exportChartData.length > 0 ? ( + + + + `${value.split(" ")[0]} ${formatNumber(value.split(" ")[1]).replaceAll('٬','')}`} + /> + `${formatNumber(value)} میلیون`}/> + + `${formatNumber(value)}`} + position="top" + offset={15} + fill="F9FAFB" + className="fill-foreground" + fontSize={16} + /> + + + + + ) : ( +
+ داده‌ای برای نمایش وجود ندارد +
+ )} +
+
+ + {/* Export Revenue Line Chart */} +
+

ظرفیت صادر شده

+
+ {popupLoading ? ( +
+
در حال بارگذاری...
+
+ ) : allExportData.length > 0 ? ( + + + + ( + + + {payload.value} + + + )} + /> + `${formatNumber(value)} میلیون`} // 👈 اضافه کردن M کنار اعداد + /> + + `${formatNumber(value)} میلیون`} + contentStyle={{ + backgroundColor: "#1F2937", + border: "1px solid #374151", + borderRadius: "6px", + padding: "6px 10px", + fontSize: "11px", + color: "#F9FAFB", + }} + /> + + + {[...new Set(allExportData.map((item) => item.product_title))] + .slice(0, 5) + .map((product, index) => { + const colors = ["#10B981", "#EF4444", "#3B82F6", "#F59E0B", "#8B5CF6"]; + return ( + + ); + })} + + + + ) : ( +
+ داده‌ای برای نمایش وجود ندارد +
+ )} +
+
+
+ )} + + +
+
+
+ + ); +} + + \ No newline at end of file diff --git a/app/components/dashboard/sidebar.tsx b/app/components/dashboard/sidebar.tsx index 8f23cbb..4148e01 100644 --- a/app/components/dashboard/sidebar.tsx +++ b/app/components/dashboard/sidebar.tsx @@ -219,21 +219,15 @@ export function Sidebar({ if (item.id === "strategic-alignment") { return ( ) diff --git a/app/components/ui/funnel-chart.test.tsx b/app/components/ui/funnel-chart.test.tsx new file mode 100644 index 0000000..3d2f68c --- /dev/null +++ b/app/components/ui/funnel-chart.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(screen.queryByText('قيف فرآیند پروژه ها')).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/ui/funnel-chart.tsx b/app/components/ui/funnel-chart.tsx new file mode 100644 index 0000000..87e4e83 --- /dev/null +++ b/app/components/ui/funnel-chart.tsx @@ -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 ( +
+ {title && ( +

+ {title} +

+ )} + +
+ {/* Start Process Line */} +
+
ابتدا فرآیند
+
+
+
۱۰۰%
+
+
+
+
+
+ + {/* Funnel Bars */} +
+ {data.map((item, index) => { + const widthPercentage = toPercent(item.value); + const barWidth = Math.max(20, widthPercentage); // Minimum 20% width + + return ( +
+
+ {item.label} +
+
+
+
+
+ + {item.value.toLocaleString('fa-IR')} + +
+
+
+
+
+ ); + })} +
+ + {/* End Process Line */} +
+
انتها فرآیند
+
+ {(() => { + const lastValue = data[data.length - 1]?.value ?? 0; + const percent = toPercent(lastValue); + return ( +
+
{formatNumber(percent)}%
+
+
+
+ ); + })()} +
+
+
+
+ ); +} diff --git a/app/components/ui/popover.tsx b/app/components/ui/popover.tsx new file mode 100644 index 0000000..9be5957 --- /dev/null +++ b/app/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "~/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/app/lib/utils.ts b/app/lib/utils.ts index dc79b88..2bc7fa1 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +import moment from "moment-jalaali"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -20,3 +21,23 @@ export const formatCurrency = (amount: string | number) => { if (isNaN(numericAmount)) return "0 ریال"; return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; }; + + + +export const handleDataValue = (val: any): any => { +moment.loadPersian({ usePersianDigits: true }); + if (val == null) return val; + if ( + typeof val === "string" && + /^\d{4}[-/]\d{2}[-/]\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(val) + ) { + return moment(val, "YYYY-MM-DD HH:mm:ss").format("YYYY/MM/DD"); + } + if ( + typeof val === "number" || + (typeof val === "string" && /^-?\d+$/.test(val)) + ) { + return val.toString().replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); + } + return val; +} diff --git a/app/routes.ts b/app/routes.ts index 7eff565..c5da8c8 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -7,6 +7,10 @@ export default [ route( "dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx" + ), + route( + "dashboard/innovation-basket/product-innovation", + "routes/innovation-basket.product-innovation.tsx" ), route( "dashboard/innovation-basket/green-innovation", diff --git a/app/routes/innovation-basket.product-innovation.tsx b/app/routes/innovation-basket.product-innovation.tsx new file mode 100644 index 0000000..a3fab3f --- /dev/null +++ b/app/routes/innovation-basket.product-innovation.tsx @@ -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 ( + + + + ); +} \ No newline at end of file diff --git a/package.json b/package.json index e855d04..fcd6efb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0691b8..b6aaa68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-label': specifier: ^2.0.2 version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -645,6 +648,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -3009,6 +3025,29 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)