diff --git a/app/components/auth/global-route-guard.tsx b/app/components/auth/global-route-guard.tsx index 2ea930c..0db7e06 100644 --- a/app/components/auth/global-route-guard.tsx +++ b/app/components/auth/global-route-guard.tsx @@ -70,7 +70,16 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) { // Case 1: User accessing protected route without authentication if (isProtectedRoute && !isAuthenticated) { - toast.error("برای دسترسی به این صفحه باید وارد شوید"); + // if just logged out, don't show another auth-required toast + const justLoggedOut = sessionStorage.getItem("justLoggedOut") === "1"; + if (!justLoggedOut) { + toast.remove("auth-required"); + toast.error("برای دسترسی به این صفحه باید وارد شوید", { + id: "auth-required", + }); + } else { + sessionStorage.removeItem("justLoggedOut"); + } // Save the intended destination for after login const returnTo = encodeURIComponent(currentPath + location.search); @@ -80,7 +89,10 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) { // Case 2: User accessing protected route with expired/invalid token if (isProtectedRoute && isAuthenticated && (!token || !token.accessToken)) { - toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); + toast.remove("session-expired"); + toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید", { + id: "session-expired", + }); // Clear invalid auth data localStorage.removeItem("auth_user"); @@ -125,7 +137,10 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) { navigate("/404", { replace: true }); } else { // If user is not authenticated, redirect to login - toast.error("صفحه مورد نظر یافت نشد. لطفاً وارد شوید"); + toast.remove("not-found-login"); + toast.error("صفحه مورد نظر یافت نشد. لطفاً وارد شوید", { + id: "not-found-login", + }); navigate("/login", { replace: true }); } return; @@ -137,7 +152,10 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) { try { const isValid = await validateToken(); if (!isValid) { - toast.error("جلسه کاری شما منقضی شده است"); + toast.remove("session-expired-soft"); + toast.error("جلسه کاری شما منقضی شده است", { + id: "session-expired-soft", + }); navigate("/unauthorized?reason=token-expired", { replace: true }); } } catch (error) { @@ -168,30 +186,7 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) { navigate, ]); - // Validate token periodically for authenticated users - useEffect(() => { - if (!isAuthenticated || !token?.accessToken) return; - - const validateTokenPeriodically = async () => { - try { - const isValid = await validateToken(); - if (!isValid) { - toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); - navigate("/login", { replace: true }); - } - } catch (error) { - console.error("Token validation error:", error); - } - }; - - // Validate token immediately - validateTokenPeriodically(); - - // Set up periodic validation (every 5 minutes) - const interval = setInterval(validateTokenPeriodically, 5 * 60 * 1000); - - return () => clearInterval(interval); - }, [isAuthenticated, token, validateToken, navigate]); + // Note: periodic validation is handled in the block above and the auth context; avoid duplicating it here. // Show loading screen while checking authentication if (isLoading) { diff --git a/app/components/auth/protected-route.tsx b/app/components/auth/protected-route.tsx index 2ac7339..a88718f 100644 --- a/app/components/auth/protected-route.tsx +++ b/app/components/auth/protected-route.tsx @@ -1,7 +1,6 @@ import React from "react"; import { useAuth } from "~/contexts/auth-context"; -import { Navigate, useLocation } from "react-router"; -import toast from "react-hot-toast"; +import { useLocation } from "react-router"; import { LoadingPage } from "~/components/ui/loading"; interface ProtectedRouteProps { @@ -49,31 +48,37 @@ export function ProtectedRoute({ ); } - // If authentication is required but user is not authenticated - if (requireAuth && !isAuthenticated) { - toast.error("برای دسترسی به این صفحه باید وارد شوید"); - - // Save the current location so we can redirect back after login - const currentPath = location.pathname + location.search; - const loginPath = `${redirectTo}?returnTo=${encodeURIComponent(currentPath)}`; - - return ; - } - - // If authentication is required but token is missing/invalid - if (requireAuth && isAuthenticated && (!token || !token.accessToken)) { - toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); - - // Clear any stored authentication data - localStorage.removeItem("auth_user"); - localStorage.removeItem("auth_token"); - - return ; - } - - // If user is authenticated but trying to access login page - if (!requireAuth && isAuthenticated && location.pathname === "/login") { - return ; + // If access is not allowed, render fallback and let the global route guard handle navigation/toasts + if ( + (requireAuth && !isAuthenticated) || + (requireAuth && isAuthenticated && (!token || !token.accessToken)) || + (!requireAuth && isAuthenticated && location.pathname === "/login") + ) { + return ( + fallback || ( +
+
+
+
+
+
+

+ در حال انتقال... +

+

+ لطفاً منتظر بمانید +

+
+
+
+ ) + ); } // If all checks pass, render the protected content diff --git a/app/components/dashboard/project-management/process-innovation-page.tsx b/app/components/dashboard/project-management/process-innovation-page.tsx index f12324d..b4e7eaa 100644 --- a/app/components/dashboard/project-management/process-innovation-page.tsx +++ b/app/components/dashboard/project-management/process-innovation-page.tsx @@ -468,7 +468,6 @@ export function ProcessInnovationPage() { onClick={() => handleProjectDetails(item)} className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto" > - جزئیات بیشتر ); @@ -482,7 +481,7 @@ export function ProcessInnovationPage() { return ( {String(value)} @@ -495,9 +494,7 @@ export function ProcessInnovationPage() { variant="outline" className="font-medium border-2" style={{ - color: getStatusColor(String(value)), - borderColor: getStatusColor(String(value)), - backgroundColor: `${getStatusColor(String(value))}20`, + border:"none", }} > {String(value)} @@ -593,7 +590,7 @@ export function ProcessInnovationPage() { {/* Chart Container */} -
+
{/* Chart Item 1 */}
کاهش توقفات تولید @@ -661,10 +658,10 @@ export function ProcessInnovationPage() { {formatNumber(((stats.percentFailuresReduction ?? 0) as number).toFixed?.(1) ?? (stats.percentFailuresReduction ?? 0))}%
-
+
+ - {/* Percentage Scale */} -
+
۰٪ { (() => { @@ -684,6 +681,10 @@ export function ProcessInnovationPage() { })() }
+
+
+ + {/* Percentage Scale */}
diff --git a/app/components/dashboard/project-management/project-management-page.tsx b/app/components/dashboard/project-management/project-management-page.tsx index d587108..a5a5b61 100644 --- a/app/components/dashboard/project-management/project-management-page.tsx +++ b/app/components/dashboard/project-management/project-management-page.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { DashboardLayout } from "../layout"; import { Card, CardContent } from "~/components/ui/card"; -import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Table, @@ -42,30 +41,40 @@ interface ProjectData { } interface SortConfig { - field: string; + field: string; // uses column.key direction: "asc" | "desc"; } -const columns = [ +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: "title", label: "عنوان پروژه", sortable: true, width: "200px" }, - { key: "importance_project", label: "میزان اهمیت", sortable: true, width: "150px" }, + { key: "importance_project", 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: "execution_phase", label: "فاز اجرایی", sortable: true, width: "140px" }, // API فعلاً نداره، باید اضافه شه + { 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" }, // API فعلاً نداره - { key: "planned_end_date", label: "تاریخ پایان (برنامه‌ریزی)", sortable: true, width: "160px" }, // API نداره - { key: "extension_duration", label: "مدت زمان تمدید", sortable: true, width: "140px" }, // API نداره - { key: "end_date", label: "تاریخ پایان (واقعی)", sortable: true, width: "160px" }, - { key: "avg_schedule_deviation", label: "متوسط انحراف برنامه‌ای", sortable: true, width: "160px" }, // API نداره + { 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: "avg_cost_deviation", label: "متوسط انحراف هزینه‌ای", sortable: true, width: "160px" }, // API نداره + { key: "cost_deviation", label: "متوسط انحراف هزینه‌ای", sortable: true, width: "160px" } ]; @@ -103,28 +112,16 @@ export function ProjectManagementPage() { 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: "project", - OutputFields: [ - "project_no", - "title", - "importance_project", - "strategic_theme", - "value_technology_and_innovation", - "type_of_innovation", - "innovation", - "person_executing", - "excellent_observer", - "observer", - "moderator", - "start_date", - "end_date", - "done_date", - "approved_budget", - "budget_spent", - ], + OutputFields: outputFields, Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, - Sorts: [[sortConfig.field, sortConfig.direction]], + Sorts: sortField ? [[sortField, sortConfig.direction]] : [], Conditions: [], }); @@ -376,11 +373,10 @@ export function ProjectManagementPage() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }; - const calculateRemainingDays = (start: string | null, end: string | null): number | null => { - if (!start || !end) return null; // if either missing - const startDate = parseToDate(start); + const calculateRemainingDays = (end: string | null): number | null => { + if (!end) return null; // if either missing const endDate = parseToDate(end); - if (!startDate || !endDate) return null; + 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); @@ -437,19 +433,20 @@ export function ProjectManagementPage() { } }; - const renderCellContent = (item: ProjectData, column: any) => { - const value = item[column.key as keyof ProjectData]; + 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.start_date, item.end_date); + const days = calculateRemainingDays(item.end_date); if (days == null) { return -; } const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined; return ( - - {toPersianDigits(days)} + + روز {toPersianDigits(days)} ); } @@ -472,6 +469,23 @@ export function ProjectManagementPage() { {formatCurrency(String(value))} ); + case "renewed_duration": { + const raw = value as any; + const numeric = typeof raw === "string" ? Number(raw) : (raw as number); + if (numeric === undefined || numeric === null || Number.isNaN(numeric)) { + return -; + } + return ( + + روز {toPersianDigits(numeric)} + + ); + } + case "deviation_from_program": + case "cost_deviation": + return ( + {formatNumber(value as any)} + ); case "start_date": case "end_date": case "done_date": @@ -504,7 +518,7 @@ export function ProjectManagementPage() { ); default: - return {String(value) || "-"}; + return {(value && String(value)) || "-"}; } }; diff --git a/app/contexts/auth-context.tsx b/app/contexts/auth-context.tsx index 70450ef..b2985eb 100644 --- a/app/contexts/auth-context.tsx +++ b/app/contexts/auth-context.tsx @@ -99,7 +99,6 @@ export function AuthProvider({ children }: AuthProviderProps) { } else { // Token is invalid, clear auth data clearAuthData(); - toast.error("جلسه کاری شما منقضی شده است"); } } catch (error) { console.error("Error parsing saved user data:", error); @@ -126,7 +125,6 @@ export function AuthProvider({ children }: AuthProviderProps) { const isValid = await validateToken(); if (!isValid) { clearAuthData(); - toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); } }, 5 * 60 * 1000, @@ -210,8 +208,12 @@ export function AuthProvider({ children }: AuthProviderProps) { } catch (error) { console.error("Logout error:", error); } finally { + // mark logout event to suppress next auth-required toast from guard + try { + sessionStorage.setItem("justLoggedOut", "1"); + } catch {} clearAuthData(); - toast.success("با موفقیت خارج شدید"); + toast.success("با موفقیت خارج شدید", { id: "logout-success" }); } }; diff --git a/app/lib/api.ts b/app/lib/api.ts index 88700f4..6e339de 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -81,20 +81,22 @@ class ApiService { } catch (error) { console.error("API request failed:", error); - // Handle network errors + // Handle network errors (propagate up; UI decides how to toast) if (error instanceof TypeError && error.message.includes("fetch")) { - toast.error( - "خطا در اتصال به سرور. لطفاً اتصال اینترنت خود را بررسی کنید", - ); - throw new Error("شبکه در دسترس نیست"); + const err = Object.assign(new Error("شبکه در دسترس نیست"), { + code: "NETWORK_ERROR", + }); + throw err; } // Handle authentication errors if (error instanceof Error && error.message.includes("401")) { - toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); this.clearToken(); localStorage.removeItem("auth_token"); localStorage.removeItem("auth_user"); + try { + sessionStorage.setItem("sessionExpired", "1"); + } catch {} window.location.href = "/login"; throw error; } diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 68a989a..353e81e 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -4,7 +4,6 @@ import { PublicRoute } from "~/components/auth/protected-route"; import { useAuth } from "~/contexts/auth-context"; import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; -import { LoadingPage } from "~/components/ui/loading"; export function meta({}: Route.MetaArgs) { return [