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 [