fix logout and gurad ,also fix the table on project management ,fix the style in process-innovation page

This commit is contained in:
Saeed AB 2025-08-13 02:40:50 +03:30
parent 26e024f9ac
commit fa9aa8eedd
7 changed files with 133 additions and 115 deletions

View File

@ -70,7 +70,16 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
// Case 1: User accessing protected route without authentication // Case 1: User accessing protected route without authentication
if (isProtectedRoute && !isAuthenticated) { 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 // Save the intended destination for after login
const returnTo = encodeURIComponent(currentPath + location.search); 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 // Case 2: User accessing protected route with expired/invalid token
if (isProtectedRoute && isAuthenticated && (!token || !token.accessToken)) { if (isProtectedRoute && isAuthenticated && (!token || !token.accessToken)) {
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); toast.remove("session-expired");
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید", {
id: "session-expired",
});
// Clear invalid auth data // Clear invalid auth data
localStorage.removeItem("auth_user"); localStorage.removeItem("auth_user");
@ -125,7 +137,10 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
navigate("/404", { replace: true }); navigate("/404", { replace: true });
} else { } else {
// If user is not authenticated, redirect to login // 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 }); navigate("/login", { replace: true });
} }
return; return;
@ -137,7 +152,10 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
try { try {
const isValid = await validateToken(); const isValid = await validateToken();
if (!isValid) { if (!isValid) {
toast.error("جلسه کاری شما منقضی شده است"); toast.remove("session-expired-soft");
toast.error("جلسه کاری شما منقضی شده است", {
id: "session-expired-soft",
});
navigate("/unauthorized?reason=token-expired", { replace: true }); navigate("/unauthorized?reason=token-expired", { replace: true });
} }
} catch (error) { } catch (error) {
@ -168,30 +186,7 @@ export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
navigate, navigate,
]); ]);
// Validate token periodically for authenticated users // Note: periodic validation is handled in the block above and the auth context; avoid duplicating it here.
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]);
// Show loading screen while checking authentication // Show loading screen while checking authentication
if (isLoading) { if (isLoading) {

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { useAuth } from "~/contexts/auth-context"; import { useAuth } from "~/contexts/auth-context";
import { Navigate, useLocation } from "react-router"; import { useLocation } from "react-router";
import toast from "react-hot-toast";
import { LoadingPage } from "~/components/ui/loading"; import { LoadingPage } from "~/components/ui/loading";
interface ProtectedRouteProps { interface ProtectedRouteProps {
@ -49,31 +48,37 @@ export function ProtectedRoute({
); );
} }
// If authentication is required but user is not authenticated // If access is not allowed, render fallback and let the global route guard handle navigation/toasts
if (requireAuth && !isAuthenticated) { if (
toast.error("برای دسترسی به این صفحه باید وارد شوید"); (requireAuth && !isAuthenticated) ||
(requireAuth && isAuthenticated && (!token || !token.accessToken)) ||
// Save the current location so we can redirect back after login (!requireAuth && isAuthenticated && location.pathname === "/login")
const currentPath = location.pathname + location.search; ) {
const loginPath = `${redirectTo}?returnTo=${encodeURIComponent(currentPath)}`; return (
fallback || (
return <Navigate to={loginPath} replace />; <div
} className="min-h-screen flex items-center justify-center"
style={{
// If authentication is required but token is missing/invalid background:
if (requireAuth && isAuthenticated && (!token || !token.accessToken)) { "linear-gradient(135deg, var(--color-login-dark-start) 0%, var(--color-login-dark-end) 100%)",
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید"); }}
>
// Clear any stored authentication data <div className="text-center space-y-6 max-w-md mx-auto p-8">
localStorage.removeItem("auth_user"); <div className="flex justify-center">
localStorage.removeItem("auth_token"); <div className="w-8 h-8 border-2 border-[var(--color-login-primary)] border-t-transparent rounded-full animate-spin"></div>
</div>
return <Navigate to="/login" replace />; <div className="space-y-2">
} <h2 className="text-lg font-medium font-persian text-white">
در حال انتقال...
// If user is authenticated but trying to access login page </h2>
if (!requireAuth && isAuthenticated && location.pathname === "/login") { <p className="text-sm font-persian leading-relaxed text-gray-300">
return <Navigate to="/dashboard" replace />; لطفاً منتظر بمانید
</p>
</div>
</div>
</div>
)
);
} }
// If all checks pass, render the protected content // If all checks pass, render the protected content

View File

@ -468,7 +468,6 @@ export function ProcessInnovationPage() {
onClick={() => handleProjectDetails(item)} onClick={() => handleProjectDetails(item)}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto" className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto"
> >
<ExternalLink className="w-4 h-4 ml-1" />
جزئیات بیشتر جزئیات بیشتر
</Button> </Button>
); );
@ -482,7 +481,7 @@ export function ProcessInnovationPage() {
return ( return (
<Badge <Badge
variant="outline" variant="outline"
className="font-mono text-emerald-400 border-emerald-500/50" className="font-mono"
> >
{String(value)} {String(value)}
</Badge> </Badge>
@ -495,9 +494,7 @@ export function ProcessInnovationPage() {
variant="outline" variant="outline"
className="font-medium border-2" className="font-medium border-2"
style={{ style={{
color: getStatusColor(String(value)), border:"none",
borderColor: getStatusColor(String(value)),
backgroundColor: `${getStatusColor(String(value))}20`,
}} }}
> >
{String(value)} {String(value)}
@ -593,7 +590,7 @@ export function ProcessInnovationPage() {
</div> </div>
{/* Chart Container */} {/* Chart Container */}
<div className="space-y-6"> <div className="space-y-6 flex flex-col">
{/* Chart Item 1 */} {/* Chart Item 1 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-white font-persian text-sm min-w-[140px] text-right">کاهش توقفات تولید</span> <span className="text-white font-persian text-sm min-w-[140px] text-right">کاهش توقفات تولید</span>
@ -661,10 +658,10 @@ export function ProcessInnovationPage() {
{formatNumber(((stats.percentFailuresReduction ?? 0) as number).toFixed?.(1) ?? (stats.percentFailuresReduction ?? 0))}% {formatNumber(((stats.percentFailuresReduction ?? 0) as number).toFixed?.(1) ?? (stats.percentFailuresReduction ?? 0))}%
</span> </span>
</div> </div>
</div> <div className="flex items-center ml-[50px] gap-3">
<span className="min-w-[140px]"></span>
{/* Percentage Scale */} <div className="flex w-full justify-between pt-4 border-t border-gray-700">
<div className="flex justify-between mt-6 pt-4 border-t border-gray-700">
<span className="text-gray-400 text-xs">۰٪</span> <span className="text-gray-400 text-xs">۰٪</span>
{ {
(() => { (() => {
@ -684,6 +681,10 @@ export function ProcessInnovationPage() {
})() })()
} }
</div> </div>
</div>
</div>
{/* Percentage Scale */}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,7 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { DashboardLayout } from "../layout"; import { DashboardLayout } from "../layout";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { import {
Table, Table,
@ -42,30 +41,40 @@ interface ProjectData {
} }
interface SortConfig { interface SortConfig {
field: string; field: string; // uses column.key
direction: "asc" | "desc"; 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: "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: "strategic_theme", label: "مضمون راهبردی", sortable: true, width: "160px" },
{ key: "value_technology_and_innovation", label: "ارزش فناوری و نوآوری", sortable: true, width: "200px" }, { key: "value_technology_and_innovation", label: "ارزش فناوری و نوآوری", sortable: true, width: "200px" },
{ key: "type_of_innovation", label: "انواع نوآوری", sortable: true, width: "140px" }, { key: "type_of_innovation", label: "انواع نوآوری", sortable: true, width: "140px" },
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" }, { key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" },
{ key: "person_executing", label: "مسئول اجرا", sortable: true, width: "140px" }, { key: "person_executing", label: "مسئول اجرا", sortable: true, width: "140px" },
{ key: "excellent_observer", label: "ناطر عالی", sortable: true, width: "140px" },
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" }, { key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
{ key: "moderator", label: "مجری", sortable: true, width: "140px" }, { key: "moderator", label: "مجری", sortable: true, width: "140px" },
{ key: "execution_phase", label: "فاز اجرایی", sortable: true, width: "140px" }, // API فعلاً نداره، باید اضافه شه { key: "executive_phase", label: "فاز اجرایی", sortable: true, width: "140px" },
{ key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" }, { key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
{ key: "remaining_time", label: "زمان باقی مانده", sortable: true, width: "140px" }, // API فعلاً نداره { key: "remaining_time", label: "زمان باقی مانده", sortable: true, width: "140px", computed: true },
{ key: "planned_end_date", label: "تاریخ پایان (برنامه‌ریزی)", sortable: true, width: "160px" }, // API نداره { key: "end_date", label: "تاریخ پایان (برنامه‌ریزی)", sortable: true, width: "160px" },
{ key: "extension_duration", label: "مدت زمان تمدید", sortable: true, width: "140px" }, // API نداره { key: "renewed_duration", label: "مدت زمان تمدید", sortable: true, width: "140px" },
{ key: "end_date", label: "تاریخ پایان (واقعی)", sortable: true, width: "160px" }, { key: "done_date", label: "تاریخ پایان (واقعی)", sortable: true, width: "160px" },
{ key: "avg_schedule_deviation", label: "متوسط انحراف برنامه‌ای", sortable: true, width: "160px" }, // API نداره { key: "deviation_from_program", label: "متوسط انحراف برنامه‌ای", sortable: true, width: "160px" },
{ key: "approved_budget", label: "بودجه مصوب", sortable: true, width: "150px" }, { key: "approved_budget", label: "بودجه مصوب", sortable: true, width: "150px" },
{ key: "budget_spent", 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 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({ const response = await apiService.select({
ProcessName: "project", ProcessName: "project",
OutputFields: [ OutputFields: 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",
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
Sorts: [[sortConfig.field, sortConfig.direction]], Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
Conditions: [], Conditions: [],
}); });
@ -376,11 +373,10 @@ export function ProjectManagementPage() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate()); return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}; };
const calculateRemainingDays = (start: string | null, end: string | null): number | null => { const calculateRemainingDays = (end: string | null): number | null => {
if (!start || !end) return null; // if either missing if (!end) return null; // if either missing
const startDate = parseToDate(start);
const endDate = parseToDate(end); const endDate = parseToDate(end);
if (!startDate || !endDate) return null; if (!endDate) return null;
const today = getTodayMidnight(); const today = getTodayMidnight();
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
const diff = Math.round((endDate.getTime() - today.getTime()) / MS_PER_DAY); 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 renderCellContent = (item: ProjectData, column: ColumnDef) => {
const value = item[column.key as keyof ProjectData]; const apiField = column.apiField ?? column.key;
const value = (item as any)[apiField];
switch (column.key) { switch (column.key) {
case "remaining_time": { case "remaining_time": {
const days = calculateRemainingDays(item.start_date, item.end_date); const days = calculateRemainingDays(item.end_date);
if (days == null) { if (days == null) {
return <span className="text-gray-300">-</span>; return <span className="text-gray-300">-</span>;
} }
const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined; const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined;
return ( return (
<span className="font-medium" style={{ color }}> <span dir="ltr" className="font-medium flex justify-end gap-1 items-center" style={{ color }}>
{toPersianDigits(days)} <span>روز</span> {toPersianDigits(days)}
</span> </span>
); );
} }
@ -472,6 +469,23 @@ export function ProjectManagementPage() {
{formatCurrency(String(value))} {formatCurrency(String(value))}
</span> </span>
); );
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 <span className="text-gray-300">-</span>;
}
return (
<span dir="ltr" className="font-medium flex justify-end gap-1 items-center text-gray-300">
<span>روز</span> {toPersianDigits(numeric)}
</span>
);
}
case "deviation_from_program":
case "cost_deviation":
return (
<span className="text-gray-300">{formatNumber(value as any)}</span>
);
case "start_date": case "start_date":
case "end_date": case "end_date":
case "done_date": case "done_date":
@ -504,7 +518,7 @@ export function ProjectManagementPage() {
</Badge> </Badge>
); );
default: default:
return <span className="text-gray-300">{String(value) || "-"}</span>; return <span className="text-gray-300">{(value && String(value)) || "-"}</span>;
} }
}; };

View File

@ -99,7 +99,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
} else { } else {
// Token is invalid, clear auth data // Token is invalid, clear auth data
clearAuthData(); clearAuthData();
toast.error("جلسه کاری شما منقضی شده است");
} }
} catch (error) { } catch (error) {
console.error("Error parsing saved user data:", error); console.error("Error parsing saved user data:", error);
@ -126,7 +125,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
const isValid = await validateToken(); const isValid = await validateToken();
if (!isValid) { if (!isValid) {
clearAuthData(); clearAuthData();
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
} }
}, },
5 * 60 * 1000, 5 * 60 * 1000,
@ -210,8 +208,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
} catch (error) { } catch (error) {
console.error("Logout error:", error); console.error("Logout error:", error);
} finally { } finally {
// mark logout event to suppress next auth-required toast from guard
try {
sessionStorage.setItem("justLoggedOut", "1");
} catch {}
clearAuthData(); clearAuthData();
toast.success("با موفقیت خارج شدید"); toast.success("با موفقیت خارج شدید", { id: "logout-success" });
} }
}; };

View File

@ -81,20 +81,22 @@ class ApiService {
} catch (error) { } catch (error) {
console.error("API request failed:", 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")) { if (error instanceof TypeError && error.message.includes("fetch")) {
toast.error( const err = Object.assign(new Error("شبکه در دسترس نیست"), {
"خطا در اتصال به سرور. لطفاً اتصال اینترنت خود را بررسی کنید", code: "NETWORK_ERROR",
); });
throw new Error("شبکه در دسترس نیست"); throw err;
} }
// Handle authentication errors // Handle authentication errors
if (error instanceof Error && error.message.includes("401")) { if (error instanceof Error && error.message.includes("401")) {
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
this.clearToken(); this.clearToken();
localStorage.removeItem("auth_token"); localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user"); localStorage.removeItem("auth_user");
try {
sessionStorage.setItem("sessionExpired", "1");
} catch {}
window.location.href = "/login"; window.location.href = "/login";
throw error; throw error;
} }

View File

@ -4,7 +4,6 @@ import { PublicRoute } from "~/components/auth/protected-route";
import { useAuth } from "~/contexts/auth-context"; import { useAuth } from "~/contexts/auth-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import { LoadingPage } from "~/components/ui/loading";
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [