diff --git a/app/components/dashboard/project-management/mange-ideas-tech-page.tsx b/app/components/dashboard/project-management/mange-ideas-tech-page.tsx new file mode 100644 index 0000000..0d9bc8d --- /dev/null +++ b/app/components/dashboard/project-management/mange-ideas-tech-page.tsx @@ -0,0 +1,794 @@ +import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { Badge } from "~/components/ui/badge"; +import { Card, CardContent } from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import apiService from "~/lib/api"; +import { formatNumber } from "~/lib/utils"; +import { DashboardLayout } from "../layout"; + +interface ProjectData { + WorkflowID: number; + ValueP1215S1887ValueID: number; + ValueP1215S1887StageID: number; + project_no: string; + importance_project: string; + title: string; + strategic_theme: string; + value_technology_and_innovation: string; + type_of_innovation: string; + innovation: string; + person_executing: string; + excellent_observer: string; + observer: string; + moderator: string; + start_date: string; + end_date: string | null; + done_date: string | null; + approved_budget: string; + budget_spent: string; +} + +interface SortConfig { + field: string; // uses column.key + direction: "asc" | "desc"; +} + +type ColumnDef = { + key: string; // UI key + label: string; + sortable: boolean; + width: string; + apiField?: string; // API field name; defaults to key + computed?: boolean; // not fetched from API +}; + +// const columns: ColumnDef[] = [ +// { key: "idea_title", label: "عنوان پروژه", sortable: true, width: "200px" }, +// { +// key: "idea_status", +// label: "میزان اهمیت", +// sortable: true, +// width: "150px", +// }, +// { +// key: "strategic_theme", +// label: "مضمون راهبردی", +// sortable: true, +// width: "160px", +// }, +// { +// key: "value_technology_and_innovation", +// label: "ارزش فناوری و نوآوری", +// sortable: true, +// width: "200px", +// }, +// { +// key: "type_of_innovation", +// label: "انواع نوآوری", +// sortable: true, +// width: "140px", +// }, +// { key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" }, +// { +// key: "person_executing", +// label: "مسئول اجرا", +// sortable: true, +// width: "140px", +// }, +// { +// key: "excellent_observer", +// label: "ناطر عالی", +// sortable: true, +// width: "140px", +// }, +// { key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" }, +// { key: "moderator", label: "مجری", sortable: true, width: "140px" }, +// { +// key: "executive_phase", +// label: "فاز اجرایی", +// sortable: true, +// width: "140px", +// }, +// { 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", +// }, +// ]; + +const columns: ColumnDef[] = [ + { key: "idea_title", label: "عنوان ایده", sortable: true, width: "200px" }, + { key: "idea_axis", label: "محور ایده", sortable: true, width: "160px" }, + { + key: "idea_current_status_description", + label: "توضیح وضعیت فعلی ایده", + sortable: true, + width: "220px", + }, + { + key: "idea_description", + label: "شرح ایده", + sortable: true, + width: "200px", + }, + { + key: "full_name", + label: "نام و نام خانوادگی", + sortable: true, + width: "160px", + }, + { + key: "idea_status", + label: "وضعیت ایده", + sortable: true, + width: "260px", + }, + { + key: "personnel_number", + label: "شماره پرسنلی", + sortable: true, + width: "140px", + }, + { + key: "innovator_team_members", + label: "اعضای تیم نوآور", + sortable: true, + width: "200px", + }, + { + key: "idea_registration_date", + label: "تاریخ ثبت ایده", + sortable: true, + width: "160px", + }, + { key: "deputy", label: "معاونت مربوطه", sortable: true, width: "160px" }, + { key: "management", label: "مدیریت", sortable: true, width: "140px" }, + { + key: "idea_execution_benefits", + label: "مزایای اجرای ایده", + sortable: true, + width: "220px", + }, + { + key: "innovation_type", + label: "نوع نوآوری", + sortable: true, + width: "160px", + }, + { + key: "idea_originality", + label: "میزان اصالت ایده", + sortable: true, + width: "160px", + }, + { + key: "idea_income", + label: "درآمد حاصل از ایده", + sortable: true, + width: "160px", + }, + { + key: "process_improvements", + label: "بهبودهای فرآیندی", + sortable: true, + width: "180px", + }, +]; + +// idea_income , idea_registration_date , idea_originality , personnel_number + +export function ManageIdeasTechPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(25); + const [hasMore, setHasMore] = useState(true); + const [totalCount, setTotalCount] = useState(0); + const [actualTotalCount, setActualTotalCount] = useState(0); + const [sortConfig, setSortConfig] = useState({ + field: "idea_title", + direction: "asc", + }); + const observerRef = useRef(null); + const fetchingRef = useRef(false); + + const fetchProjects = async (reset = false) => { + // Prevent concurrent API calls + if (fetchingRef.current) { + return; + } + + try { + fetchingRef.current = true; + + if (reset) { + setLoading(true); + setCurrentPage(1); + } else { + setLoadingMore(true); + } + + const pageToFetch = reset ? 1 : currentPage; + + 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: "idea", + OutputFields: outputFields, + Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, + Sorts: sortField ? [[sortField, sortConfig.direction]] : [], + Conditions: [], + }); + + if (response.state === 0) { + // Parse the JSON string from the API response + 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); + } + + // Check if there are more items to load + 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 loadMore = useCallback(() => { + if (!loadingMore && hasMore && !loading) { + setCurrentPage((prev) => prev + 1); + } + }, [loadingMore, hasMore, loading]); + + useEffect(() => { + fetchProjects(true); + fetchTotalCount(); + }, [sortConfig]); + + useEffect(() => { + if (currentPage > 1) { + fetchProjects(false); + } + }, [currentPage]); + + // Infinite scroll observer + useEffect(() => { + const scrollContainer = document.querySelector(".overflow-auto"); + + const handleScroll = () => { + if (!scrollContainer || !hasMore || loadingMore) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + + // Trigger load more when scrolled to 90% of the container + if (scrollPercentage >= 0.9) { + loadMore(); + } + }; + + if (scrollContainer) { + scrollContainer.addEventListener("scroll", handleScroll); + } + + return () => { + if (scrollContainer) { + scrollContainer.removeEventListener("scroll", handleScroll); + } + }; + }, [loadMore, hasMore, loadingMore]); + + const handleSort = (field: string) => { + fetchingRef.current = false; // Reset fetching state on sort + setSortConfig((prev) => ({ + field, + direction: + prev.field === field && prev.direction === "asc" ? "desc" : "asc", + })); + setCurrentPage(1); + setProjects([]); + setHasMore(true); + }; + + const fetchTotalCount = async () => { + try { + const response = await apiService.select({ + ProcessName: "idea", + OutputFields: ["count(idea_no)"], + Conditions: [], + }); + + if (response.state === 0) { + const dataString = response.data; + if (dataString && typeof dataString === "string") { + try { + const parsedData = JSON.parse(dataString); + if (Array.isArray(parsedData) && parsedData[0]) { + setActualTotalCount(parsedData[0].project_no_count || 0); + } + } catch (parseError) { + console.error("Error parsing count data:", parseError); + } + } + } + } catch (error) { + console.error("Error fetching total count:", error); + } + }; + + const formatCurrency = (amount: string | number) => { + if (!amount) return "0 ریال"; + // Remove commas and convert to number + const numericAmount = + typeof amount === "string" + ? parseFloat(amount.replace(/,/g, "")) + : amount; + if (isNaN(numericAmount)) return "0 ریال"; + return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; + }; + + // 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 str = String(input); + const map: Record = { + "0": "۰", + "1": "۱", + "2": "۲", + "3": "۳", + "4": "۴", + "5": "۵", + "6": "۶", + "7": "۷", + "8": "۸", + "9": "۹", + }; + return str.replace(/[0-9]/g, (d) => map[d] ?? d); + }; + + // ----- Jalali <-> Gregorian conversion helpers (lightweight, local) ----- + const isJalaliDateString = (raw: string): boolean => { + return /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/.test(raw.trim()); + }; + + const div = (a: number, b: number) => ~~(a / b); + + const jalaliToJDN = (jy: number, jm: number, jd: number): number => { + jy = jy - (jy >= 0 ? 474 : 473); + const cycle = 1029983; + const yCycle = 474 + (jy % 2820); + const jdn = + jd + + (jm <= 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186) + + div((yCycle * 682 - 110) as number, 2816) + + (yCycle - 1) * 365 + + div(jy, 2820) * cycle + + (1948320 - 1); + return jdn; + }; + + const jdnToGregorian = (jdn: number): [number, number, number] => { + let j = jdn + 32044; + const g = div(j, 146097); + const dg = j % 146097; + const c = div((div(dg, 36524) + 1) * 3, 4); + const dc = dg - c * 36524; + const b = div(dc, 1461); + const db = dc % 1461; + const a = div((div(db, 365) + 1) * 3, 4); + const da = db - a * 365; + const y = g * 400 + c * 100 + b * 4 + a; + const m = div(da * 5 + 308, 153) - 2; + const d = da - div((m + 4) * 153, 5) + 122; + const year = y - 4800 + div(m + 2, 12); + const month = ((m + 2) % 12) + 1; + const day = d + 1; + return [year, month, day]; + }; + + const jalaliToGregorianDate = (jy: number, jm: number, jd: number): Date => { + const jdn = jalaliToJDN(jy, jm, jd); + const [gy, gm, gd] = jdnToGregorian(jdn); + return new Date(gy, gm - 1, gd); + }; + + const parseToDate = (value: string | null): Date | null => { + if (!value) return null; + const raw = String(value).trim(); + if (isJalaliDateString(raw)) { + const [jy, jm, jd] = raw.split("/").map((s) => Number(s)); + if ([jy, jm, jd].some((n) => Number.isNaN(n))) return null; + return jalaliToGregorianDate(jy, jm, jd); + } + const tryDate = new Date(raw); + return Number.isNaN(tryDate.getTime()) ? null : tryDate; + }; + + const getTodayMidnight = (): Date => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + }; + + const calculateRemainingDays = (end: string | null): number | null => { + if (!end) return null; // if either missing + const endDate = parseToDate(end); + if (!endDate) return null; + const today = getTodayMidnight(); + const MS_PER_DAY = 24 * 60 * 60 * 1000; + const diff = Math.round((endDate.getTime() - today.getTime()) / MS_PER_DAY); + return diff; + }; + + const formatDate = (dateString: string | null) => { + if (!dateString || dateString === "null" || dateString.trim() === "") { + return "-"; + } + + // If API already returns Jalali like 1404/05/30, just convert digits + const raw = String(dateString).trim(); + const jalaliPattern = /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/; + const jalaliMatch = raw.match(jalaliPattern); + if (jalaliMatch) { + const [, y, m, d] = jalaliMatch; + const mm = m.padStart(2, "0"); + const dd = d.padStart(2, "0"); + return toPersianDigits(`${y}/${mm}/${dd}`); + } + + // Otherwise, try to parse and render Persian calendar + try { + const parsed = new Date(raw); + if (isNaN(parsed.getTime())) return "-"; + return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(parsed); + } catch { + return "-"; + } + }; + + const phaseColors: Record = { + "تحقیق و توسعه": "#FFD700", // Yellow + آزمایش: "#1E90FF", // Blue + تولید: "#32CD32", // Green + default: "#ccc", // Fallback gray + }; + + const getImportanceColor = (importance: string) => { + switch (importance?.toLowerCase()) { + case "تایید شده": + return "#3AEA83"; // سبز + case "در حال بررسی": + return "#69C8EA"; // آبی + case "رد شده": + return "#F76276"; // قرمز + case "اجرا شده": + return "#FBBF24"; // زرد/نارنجی + default: + return "#6B7280"; // خاکستری پیش‌فرض + } + }; + + // idea_income , idea_registration_date , idea_originality , personnel_number + + const renderCellContent = (item: ProjectData, column: ColumnDef) => { + const apiField = column.apiField ?? column.key; + const value = (item as any)[apiField]; + + switch (column.key) { + case "remaining_time": { + const days = calculateRemainingDays(item.end_date); + if (days == null) { + return -; + } + const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined; + return ( + + روز {toPersianDigits(days)} + + ); + } + // case "strategic_theme": + // case "value_technology_and_innovation": + // case "type_of_innovation": + // case "innovation": + // return ( + // + // {String(value) || "-"} + // + // + // ); + case "idea_income": + return ( + + {formatCurrency(String(value))} + + ); + case "personnel_number": + // case "idea_originality": + return ( + {formatNumber(value as any)} + ); + case "idea_registration_date": + return ( + {formatDate(String(value))} + ); + case "project_no": + return ( + + {String(value)} + + ); + case "idea_title": + return {String(value)}; + case "idea_status": + return ( + + {String(value)} + + ); + default: + return ( + + {(value && String(value)) || "-"} + + ); + } + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( + +
+ {/* Data Table */} + + +
+ + + + {columns.map((column) => ( + + {column.sortable ? ( + + ) : ( + column.label + )} + + ))} + + + + {loading ? ( + // Skeleton loading rows (compact) + Array.from({ length: 20 }).map((_, index) => ( + + {columns.map((column) => ( + +
+
+
+
+ + ))} + + )) + ) : projects.length === 0 ? ( + + + + هیچ پروژه‌ای یافت نشد + + + + ) : ( + projects.map((project, index) => ( + + {columns.map((column) => ( + + {renderCellContent(project, column)} + + ))} + + )) + )} + +
+
+ + {/* Infinite scroll trigger */} +
+ {loadingMore && ( +
+
+ + +
+
+ )} +
+
+ + {/* Footer */} +
+
+ کل پروژه‌ها: {formatNumber(actualTotalCount)} +
+
+
+
+
+ ); +} diff --git a/app/components/dashboard/sidebar.tsx b/app/components/dashboard/sidebar.tsx index 717f204..43a0878 100644 --- a/app/components/dashboard/sidebar.tsx +++ b/app/components/dashboard/sidebar.tsx @@ -1,40 +1,25 @@ +import { + Box, + Building2, + ChevronDown, + ChevronRight, + FolderKanban, + GalleryVerticalEnd, + Globe, + LayoutDashboard, + Leaf, + Lightbulb, + LogOut, + MonitorSmartphone, + Package, + Settings, + Star, + Workflow, +} from "lucide-react"; import React, { useState } from "react"; import { Link, useLocation } from "react-router"; -import { cn } from "~/lib/utils"; -import { InogenLogo } from "~/components/ui/brand-logo"; import { useAuth } from "~/contexts/auth-context"; -import { - GalleryVerticalEnd, - LayoutDashboard, - FolderOpen, - Users, - BarChart3, - Settings, - ChevronLeft, - ChevronDown, - FileText, - Calendar, - Bell, - User, - Database, - Shield, - HelpCircle, - LogOut, - ChevronRight, - Refrigerator, -} from "lucide-react"; -import { - FolderKanban, - Box, - Package, - Workflow, - MonitorSmartphone, - Leaf, - Building2, - Globe, - Lightbulb, - Star, -} from "lucide-react"; +import { cn } from "~/lib/utils"; interface SidebarProps { isCollapsed?: boolean; @@ -111,7 +96,7 @@ const menuItems: MenuItem[] = [ id: "ideas", label: "ایده‌های فناوری و نوآوری", icon: Lightbulb, - href: "/dashboard/ideas", + href: "/dashboard/manage-ideas-tech", }, { id: "top-innovations", @@ -153,7 +138,7 @@ export function Sidebar({ menuItems.forEach((item) => { if (item.children) { const hasActiveChild = item.children.some( - (child) => child.href && location.pathname === child.href, + (child) => child.href && location.pathname === child.href ); if (hasActiveChild) { newExpandedItems.push(item.id); @@ -174,7 +159,7 @@ export function Sidebar({ const item = menuItems.find((menuItem) => menuItem.id === itemId); if (item?.children) { const hasActiveChild = item.children.some( - (child) => child.href && location.pathname === child.href, + (child) => child.href && location.pathname === child.href ); // Don't collapse if a child is active if (hasActiveChild) { @@ -192,7 +177,7 @@ export function Sidebar({ if (href && location.pathname === href) return true; if (children) { return children.some( - (child) => child.href && location.pathname === child.href, + (child) => child.href && location.pathname === child.href ); } return false; @@ -204,7 +189,7 @@ export function Sidebar({ expandedItems.includes(item.id) || (item.children && item.children.some( - (child) => child.href && location.pathname === child.href, + (child) => child.href && location.pathname === child.href )); const hasChildren = item.children && item.children.length > 0; @@ -230,15 +215,14 @@ export function Sidebar({ ? " text-emerald-400 border-r-2 border-emerald-400" : "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300", isCollapsed && level === 0 && "justify-center px-2", - item.id === "logout" && - "hover:bg-red-500/10 hover:text-red-400", + item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400" )} >
{!isCollapsed && ( @@ -259,7 +243,7 @@ export function Sidebar({ )} @@ -274,9 +258,9 @@ export function Sidebar({ // Disable pointer cursor when child is active (cannot collapse) item.children && item.children.some( - (child) => child.href && location.pathname === child.href, + (child) => child.href && location.pathname === child.href ) && - "cursor-not-allowed", + "cursor-not-allowed" )} onClick={handleClick} > @@ -288,15 +272,14 @@ export function Sidebar({ ? " text-emerald-400 border-r-2 border-emerald-400" : "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300", isCollapsed && level === 0 && "justify-center px-2", - item.id === "logout" && - "hover:bg-red-500/10 hover:text-red-400", + item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400" )} >
{!isCollapsed && ( @@ -322,10 +305,10 @@ export function Sidebar({ item.children && item.children.some( (child) => - child.href && location.pathname === child.href, + child.href && location.pathname === child.href ) ? "text-emerald-400" - : "text-current", + : "text-current" )} /> )} @@ -360,7 +343,7 @@ export function Sidebar({ className={cn( " backdrop-blur-sm h-full flex flex-col transition-all duration-300", isCollapsed ? "w-16" : "w-64", - className, + className )} > {/* Header */} @@ -424,7 +407,7 @@ export function Sidebar({ {!isCollapsed && ( diff --git a/app/routes.ts b/app/routes.ts index 05a0524..7eff565 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,4 +1,4 @@ -import { type RouteConfig, index, route } from "@react-router/dev/routes"; +import { type RouteConfig, route } from "@react-router/dev/routes"; export default [ route("login", "routes/login.tsx"), @@ -21,6 +21,7 @@ export default [ "routes/digital-innovation-page.tsx" ), route("dashboard/ecosystem", "routes/ecosystem.tsx"), + route("dashboard/manage-ideas-tech", "routes/manage-ideas-tech-page.tsx"), route("404", "routes/404.tsx"), route("unauthorized", "routes/unauthorized.tsx"), route("*", "routes/$.tsx"), // Catch-all route for 404s diff --git a/app/routes/manage-ideas-tech-page.tsx b/app/routes/manage-ideas-tech-page.tsx new file mode 100644 index 0000000..f6473d9 --- /dev/null +++ b/app/routes/manage-ideas-tech-page.tsx @@ -0,0 +1,17 @@ +import { ProtectedRoute } from "~/components/auth/protected-route"; +import { ManageIdeasTechPage } from "~/components/dashboard/project-management/mange-ideas-tech-page"; + +export function meta() { + return [ + { title: "مدیریت فنواری و ایده ها" }, + { name: "description", content: "مدیریت پروژه‌های فناوری و نوآوری" }, + ]; +} + +export default function ManageIdeasTech() { + return ( + + + + ); +}