feat: update ASM UI with latest changes
This commit is contained in:
parent
f952f65515
commit
abc533cf86
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Moon, Sun, Languages, LogOut } from 'lucide-react';
|
||||
import { Moon, Sun, Languages, LogOut, UserCircle } from 'lucide-react';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
interface HeaderProps {
|
||||
@ -12,15 +12,47 @@ interface HeaderProps {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, changeLanguage } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
const [userDisplayName, setUserDisplayName] = useState<string>('');
|
||||
|
||||
// const handleLogout = () => {
|
||||
// localStorage.removeItem('user');
|
||||
// localStorage.removeItem('sid');
|
||||
// navigate('/login');
|
||||
// };
|
||||
useEffect(() => {
|
||||
const fetchUserDisplayName = async () => {
|
||||
try {
|
||||
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
const userData = await userResponse.json();
|
||||
const email = userData.message;
|
||||
|
||||
if (email) {
|
||||
const fullNameResponse = await fetch(
|
||||
`/api/resource/User/${encodeURIComponent(email)}?fields=["full_name"]`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
const fullNameData = await fullNameResponse.json();
|
||||
setUserDisplayName(fullNameData.data?.full_name || email);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user display name:', error);
|
||||
}
|
||||
|
||||
if (userEmail) {
|
||||
setUserDisplayName(userEmail);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserDisplayName();
|
||||
}, [userEmail]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('user');
|
||||
@ -32,7 +64,6 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
.find(row => row.startsWith('X-Frappe-CSRF-Token='))
|
||||
?.split('=')[1] || '';
|
||||
|
||||
// Step 1: Kill server-side session in Redis
|
||||
await fetch('/api/method/frappe.auth.logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -42,11 +73,9 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Step 2: Clear Frappe web session cookies fully
|
||||
await fetch('/?cmd=web_logout', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
} finally {
|
||||
@ -54,21 +83,29 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isProfileActive = location.pathname === '/user-profile';
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{/* User Email (optional, can be shown on hover or always) */}
|
||||
{/* {userEmail && (
|
||||
<div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
|
||||
{userEmail}
|
||||
</div>
|
||||
)} */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/user-profile')}
|
||||
className={`
|
||||
p-2 rounded-lg transition-colors text-white
|
||||
${isProfileActive
|
||||
? 'bg-indigo-700 ring-2 ring-indigo-300 dark:ring-indigo-500'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700'}
|
||||
`}
|
||||
title={userDisplayName || t('common.userProfile', { defaultValue: 'User Profile' })}
|
||||
aria-label={userDisplayName || t('common.userProfile', { defaultValue: 'User Profile' })}
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
</button>
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
@ -77,7 +114,6 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
<Languages size={20} />
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
@ -86,7 +122,6 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
|
||||
@ -99,5 +134,3 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
|
||||
|
||||
240
asm_app/src/components/QRScanner.tsx
Normal file
240
asm_app/src/components/QRScanner.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { FaCamera, FaTimes } from 'react-icons/fa';
|
||||
import { parseQrPayloadToAppPath } from '../utils/qrNavigation';
|
||||
|
||||
export interface QRScannerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Override default navigation; receive original QR text + parsed in-app path */
|
||||
onScan?: (decodedText: string, appPath: string | null) => void;
|
||||
title?: string;
|
||||
/** Prefer rear camera on phones */
|
||||
facingMode?: 'environment' | 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera QR scanner modal. On success, navigates inside the SPA (including mobile webview)
|
||||
* using React Router so users stay in the app instead of opening an external browser.
|
||||
*/
|
||||
const QRScanner: React.FC<QRScannerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onScan,
|
||||
title = 'Scan QR Code',
|
||||
facingMode = 'environment',
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const reactId = useId();
|
||||
const readerId = `qr-reader-${reactId.replace(/:/g, '')}`;
|
||||
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||
const handledRef = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const stopScanner = async () => {
|
||||
const scanner = scannerRef.current;
|
||||
scannerRef.current = null;
|
||||
if (!scanner) return;
|
||||
try {
|
||||
if (scanner.isScanning) {
|
||||
await scanner.stop();
|
||||
}
|
||||
scanner.clear();
|
||||
} catch {
|
||||
// ignore stop/clear errors when camera already released
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecoded = async (decodedText: string) => {
|
||||
if (handledRef.current) return;
|
||||
handledRef.current = true;
|
||||
|
||||
const appPath = parseQrPayloadToAppPath(decodedText);
|
||||
|
||||
if (onScan) {
|
||||
await stopScanner();
|
||||
onClose();
|
||||
onScan(decodedText, appPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appPath) {
|
||||
await stopScanner();
|
||||
onClose();
|
||||
// In-app navigation — works in mobile webview / PWA without leaving the app
|
||||
navigate(appPath);
|
||||
return;
|
||||
}
|
||||
|
||||
handledRef.current = false;
|
||||
setError(`Unrecognized QR code: ${decodedText}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
handledRef.current = false;
|
||||
setError(null);
|
||||
setStarting(true);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await stopScanner();
|
||||
if (cancelled) return;
|
||||
|
||||
const scanner = new Html5Qrcode(readerId);
|
||||
scannerRef.current = scanner;
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: (viewfinderWidth, viewfinderHeight) => {
|
||||
const edge = Math.min(viewfinderWidth, viewfinderHeight);
|
||||
const size = Math.max(180, Math.floor(edge * 0.7));
|
||||
return { width: size, height: size };
|
||||
},
|
||||
aspectRatio: 1,
|
||||
},
|
||||
(decodedText) => {
|
||||
void handleDecoded(decodedText);
|
||||
},
|
||||
() => {
|
||||
// ignore frame-level "not found" callbacks
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('QR scanner start failed:', err);
|
||||
if (!cancelled) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Could not open camera';
|
||||
setError(
|
||||
message.includes('NotAllowedError') || message.toLowerCase().includes('permission')
|
||||
? 'Camera permission denied. Please allow camera access and try again.'
|
||||
: `Camera unavailable: ${message}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay so the reader DOM node is mounted
|
||||
const timer = window.setTimeout(() => {
|
||||
void start();
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
void stopScanner();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, readerId, facingMode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10050] flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
|
||||
<FaCamera className="text-teal-600" />
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void stopScanner().then(onClose);
|
||||
}}
|
||||
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
aria-label="Close scanner"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Point the camera at an asset QR code. You will stay inside the app.
|
||||
</p>
|
||||
|
||||
{starting && !error && (
|
||||
<p className="text-center text-sm text-teal-600 dark:text-teal-400">Starting camera…</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={readerId}
|
||||
className="overflow-hidden rounded-lg bg-black [&_video]:max-h-[60vh] [&_video]:w-full [&_video]:object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void stopScanner().then(onClose);
|
||||
}}
|
||||
className="w-full rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export interface ScanQRButtonProps {
|
||||
className?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
onScan?: (decodedText: string, appPath: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Scan QR trigger + scanner modal. Use anywhere you need camera QR → in-app navigation.
|
||||
*/
|
||||
export const ScanQRButton: React.FC<ScanQRButtonProps> = ({
|
||||
className,
|
||||
label = 'Scan QR',
|
||||
title,
|
||||
onScan,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={
|
||||
className ||
|
||||
'bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 shadow transition-all w-full sm:w-auto'
|
||||
}
|
||||
>
|
||||
<FaCamera />
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
<QRScanner
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onScan={onScan}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRScanner;
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@ -15,8 +15,6 @@ import {
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
UserCircle
|
||||
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarLink {
|
||||
@ -34,12 +32,9 @@ interface SidebarProps {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isRTL } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [userFullName, setUserFullName] = useState<string>('');
|
||||
|
||||
// Get base URL for assets (handles both dev and production)
|
||||
// BASE_URL in Vite already includes trailing slash in production, but not in dev
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
@ -55,51 +50,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
? `${baseUrl}sidebar-background.jpg${imageVersion}`
|
||||
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
|
||||
|
||||
// ✅ Fetch user full name on mount
|
||||
useEffect(() => {
|
||||
const fetchUserFullName = async () => {
|
||||
try {
|
||||
// First get the logged-in user
|
||||
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
const userData = await userResponse.json();
|
||||
const userEmail = userData.message;
|
||||
|
||||
if (userEmail) {
|
||||
// Then fetch the user's full name
|
||||
const fullNameResponse = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}?fields=["full_name"]`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
const fullNameData = await fullNameResponse.json();
|
||||
|
||||
if (fullNameData.data?.full_name) {
|
||||
setUserFullName(fullNameData.data.full_name);
|
||||
} else {
|
||||
// Fallback to email if full name not found
|
||||
setUserFullName(userEmail);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user full name:', error);
|
||||
// Fallback to email prop if API fails
|
||||
if (userEmail) {
|
||||
setUserFullName(userEmail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserFullName();
|
||||
}, [userEmail]);
|
||||
|
||||
// Role-based visibility logic
|
||||
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
|
||||
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
|
||||
@ -269,13 +219,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
// ✅ Handle User Profile click
|
||||
const handleUserProfileClick = () => {
|
||||
navigate('/user-profile');
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -383,65 +326,8 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User Info & Version (Bottom) */}
|
||||
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}>
|
||||
{/* {!isCollapsed && userEmail && (
|
||||
<div>
|
||||
<div className="text-white/80 dark:text-white/80 text-xs truncate">
|
||||
{t('sidebar.loggedInAs')}
|
||||
</div>
|
||||
<div className="text-white dark:text-white text-sm font-medium truncate">
|
||||
{userEmail}
|
||||
</div> */}
|
||||
|
||||
{!isCollapsed && (userFullName || userEmail) && (
|
||||
<div>
|
||||
<div className="text-white/80 dark:text-white/80 text-xs truncate">
|
||||
{t('sidebar.loggedInAs')}
|
||||
</div>
|
||||
<div className="text-white dark:text-white text-sm font-medium truncate">
|
||||
{userFullName || userEmail}
|
||||
</div>
|
||||
|
||||
{/* ✅ User Profile Button */}
|
||||
<button
|
||||
onClick={handleUserProfileClick}
|
||||
className={`
|
||||
mt-3 w-full flex items-center justify-center gap-2
|
||||
px-3 py-2
|
||||
bg-white/20 hover:bg-white/30
|
||||
text-white
|
||||
rounded-lg
|
||||
transition-all duration-200
|
||||
text-sm font-medium
|
||||
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
|
||||
`}
|
||||
>
|
||||
<UserCircle size={18} />
|
||||
<span>User Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed state - just show icon button */}
|
||||
{isCollapsed && (
|
||||
<button
|
||||
onClick={handleUserProfileClick}
|
||||
className={`
|
||||
w-full flex items-center justify-center
|
||||
p-2
|
||||
bg-white/20 hover:bg-white/30
|
||||
text-white
|
||||
rounded-lg
|
||||
transition-all duration-200
|
||||
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
|
||||
`}
|
||||
title="User Profile"
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Version (Bottom) */}
|
||||
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 relative z-10`}>
|
||||
{!isCollapsed && (
|
||||
<div className="text-xs text-white/70 dark:text-white/70 text-center">
|
||||
{t('sidebar.version')}
|
||||
|
||||
@ -75,6 +75,7 @@ const API_CONFIG: ApiConfig = {
|
||||
LOGIN: '/api/method/login',
|
||||
LOGOUT: '/api/method/logout',
|
||||
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
|
||||
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
|
||||
|
||||
// File Upload
|
||||
UPLOAD_FILE: '/api/method/upload_file',
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"sidebar": {
|
||||
"title": "أصول سيرا",
|
||||
"loggedInAs": "تم تسجيل الدخول كـ:",
|
||||
"version": "أصول سيرا نظام إدارة الأصول الإصدار 1.0"
|
||||
"version": "أصول سيرا نظام إدارة الأصول الإصدار 2.26"
|
||||
},
|
||||
"login": {
|
||||
"title": "أصول سيرا",
|
||||
@ -41,7 +41,21 @@
|
||||
"emailPlaceholder": "أدخل بريدك الإلكتروني",
|
||||
"passwordPlaceholder": "أدخل كلمة المرور",
|
||||
"loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
|
||||
"demoLogin": "تسجيل دخول تجريبي"
|
||||
"demoLogin": "تسجيل دخول تجريبي",
|
||||
"forgotPassword": "نسيت كلمة المرور؟",
|
||||
"forgotPasswordTitle": "إعادة تعيين كلمة المرور",
|
||||
"forgotPasswordHint": "أدخل بريدك الإلكتروني أو اسم المستخدم. سنرسل لك رابطًا لإعادة تعيين كلمة المرور.",
|
||||
"forgotPasswordUserRequired": "يرجى إدخال بريدك الإلكتروني أو اسم المستخدم.",
|
||||
"forgotPasswordUserPlaceholder": "البريد الإلكتروني أو اسم المستخدم",
|
||||
"forgotPasswordSubmit": "إرسال رابط إعادة التعيين",
|
||||
"forgotPasswordClose": "إغلاق",
|
||||
"forgotPasswordSentSuccess": "إذا كان هناك حساب لهذا المستخدم، فقد أُرسلت تعليمات إعادة تعيين كلمة المرور بالبريد الإلكتروني.",
|
||||
"forgotPasswordNotFound": "لم يتم العثور على حساب بهذا البريد الإلكتروني أو اسم المستخدم.",
|
||||
"forgotPasswordTimeout": "انتهت مهلة الطلب. يرجى المحاولة مرة أخرى.",
|
||||
"forgotPasswordCannotReset": "إعادة تعيين كلمة المرور غير متاحة لهذا الحساب.",
|
||||
"forgotPasswordFailed": "تعذر إرسال رابط إعادة التعيين. يرجى المحاولة لاحقًا.",
|
||||
"finishingSignOut": "جاري إنهاء تسجيل الخروج…",
|
||||
"afterPasswordResetSignIn": "تم تحديث كلمة المرور. يرجى تسجيل الدخول بكلمة المرور الجديدة."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
"sidebar": {
|
||||
"title": "Seera-ASM",
|
||||
"loggedInAs": "Logged in as:",
|
||||
"version": "Seera-ASM v1.0",
|
||||
"version": "Seera-ASM v2.26",
|
||||
"inventory": "Inventory",
|
||||
"ppmPlanner": "PPM Planner",
|
||||
"maintenanceCalendar": "Maintenance Calendar",
|
||||
@ -51,7 +51,21 @@
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"loginFailed": "Login failed. Please check your credentials.",
|
||||
"demoLogin": "Demo Login"
|
||||
"demoLogin": "Demo Login",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"forgotPasswordTitle": "Reset your password",
|
||||
"forgotPasswordHint": "Enter your email address or username. We will send you a link to reset your password.",
|
||||
"forgotPasswordUserRequired": "Please enter your email or username.",
|
||||
"forgotPasswordUserPlaceholder": "Email or username",
|
||||
"forgotPasswordSubmit": "Send reset link",
|
||||
"forgotPasswordClose": "Close",
|
||||
"forgotPasswordSentSuccess": "If an account exists for that user, password reset instructions have been sent by email.",
|
||||
"forgotPasswordNotFound": "No account was found with that email or username.",
|
||||
"forgotPasswordTimeout": "The request timed out. Please try again.",
|
||||
"forgotPasswordCannotReset": "Password reset is not available for this account.",
|
||||
"forgotPasswordFailed": "Could not send the reset link. Please try again later.",
|
||||
"finishingSignOut": "Finishing sign-out…",
|
||||
"afterPasswordResetSignIn": "Your password was updated. Please sign in with your new password."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@ -604,7 +604,7 @@ const AssetDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewAsset && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
@ -620,7 +620,7 @@ const AssetDetail: React.FC = () => {
|
||||
|
||||
if (error && isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 dark:text-yellow-300 mb-2">
|
||||
Source Asset Not Found
|
||||
@ -2311,9 +2311,9 @@ const handlePPMPlan = async () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/assets')}
|
||||
|
||||
@ -7,7 +7,7 @@ import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownlo
|
||||
FaStar, FaLock, FaCheckSquare, FaSquare, FaFileExcel, FaFileCsv, FaWrench, FaClipboardList } from 'react-icons/fa';
|
||||
import LinkField from '../components/LinkField';
|
||||
import { useUserPermissions } from '../hooks/useUserPermissions';
|
||||
|
||||
import { ScanQRButton } from '../components/QRScanner';
|
||||
|
||||
// Export column configuration - will be defined inside component to use translations
|
||||
|
||||
@ -911,7 +911,7 @@ const AssetList: React.FC = () => {
|
||||
// ✅ Error state for permissions
|
||||
if (permissionsError) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">⚠️ Permission Error</h2>
|
||||
<div className="text-red-700 dark:text-red-400 space-y-3">
|
||||
@ -943,7 +943,7 @@ const AssetList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ Asset API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
@ -967,11 +967,11 @@ const AssetList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('assets.title')}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('assets.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} asset{totalCount !== 1 ? 's' : ''}
|
||||
{/* ✅ Show selection count */}
|
||||
@ -990,6 +990,7 @@ const AssetList: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<ScanQRButton label="Scan QR" title="Scan Asset QR" />
|
||||
{/* ✅ Updated Export Button */}
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
@ -1757,7 +1758,7 @@ const AssetList: React.FC = () => {
|
||||
<FaClipboardList className="text-cyan-500" />
|
||||
View Linked WOs
|
||||
</button>
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => {
|
||||
handleExportSingle(asset);
|
||||
setActionMenuOpen(null);
|
||||
@ -1766,7 +1767,7 @@ const AssetList: React.FC = () => {
|
||||
>
|
||||
<FaDownload className="text-blue-500" />
|
||||
Export as JSON
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handlePrint(asset.name);
|
||||
|
||||
@ -407,7 +407,7 @@ const AssetMaintenanceDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewLog && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
@ -422,7 +422,7 @@ const AssetMaintenanceDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<Toast
|
||||
@ -433,7 +433,7 @@ const AssetMaintenanceDetail: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/maintenance')}
|
||||
|
||||
@ -153,7 +153,7 @@ const AssetMaintenanceList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ Maintenance API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
@ -190,11 +190,11 @@ const AssetMaintenanceList: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('maintenance.title')}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('maintenance.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{t('listPages.total')}: {totalCount} {t('maintenance.maintenanceLogs')}
|
||||
</p>
|
||||
|
||||
@ -116,7 +116,7 @@ const EventsList: React.FC = () => {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Events</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white break-words">Events</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
|
||||
@ -206,7 +206,7 @@ const IssueDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewIssue) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issue</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
@ -225,7 +225,7 @@ const IssueDetail: React.FC = () => {
|
||||
const statusStyle = getStatusStyle(currentStatus);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
{/* Toast Container */}
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
@ -242,7 +242,7 @@ const IssueDetail: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/support')}
|
||||
|
||||
@ -392,7 +392,7 @@ const IssueList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issues</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
@ -403,9 +403,9 @@ const IssueList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
|
||||
|
||||
@ -244,7 +244,7 @@ const ItemDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewItem) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Item</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
@ -260,9 +260,9 @@ const ItemDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/inventory')}
|
||||
|
||||
@ -703,7 +703,7 @@ const ItemList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">⚠️ Error Loading Items</h2>
|
||||
<div className="text-red-700 dark:text-red-400 space-y-3">
|
||||
@ -734,11 +734,11 @@ const ItemList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Inventory</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">Inventory</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} item{totalCount !== 1 ? 's' : ''}
|
||||
{/* Show selection count */}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { loadFrappeTranslations } from '../i18n';
|
||||
import apiService, { ApiError } from '../services/apiService';
|
||||
|
||||
const SESSION_STORAGE_FLAG_KEY = 'asm_show_after_password_reset';
|
||||
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
@ -16,19 +18,114 @@ const Login: React.FC = () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forgotOpen, setForgotOpen] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
||||
const [forgotMessage, setForgotMessage] = useState<string | null>(null);
|
||||
const [pwdResetBusy, setPwdResetBusy] = useState(false);
|
||||
const [postResetBanner, setPostResetBanner] = useState(false);
|
||||
const manualLoginHandledRef = useRef(false);
|
||||
const forgotAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { isRTL } = useLanguage();
|
||||
|
||||
// Get base URL for assets
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
const logoVersion = import.meta.env.DEV
|
||||
? `?v=${Date.now()}`
|
||||
: `?v=1765198405`; // Auto-updated by build script
|
||||
const logoVersion = import.meta.env.DEV
|
||||
? `?v=${Date.now()}`
|
||||
: `?v=1765198405`;
|
||||
|
||||
const closeForgotModal = useCallback(() => {
|
||||
forgotAbortRef.current?.abort();
|
||||
forgotAbortRef.current = null;
|
||||
setForgotOpen(false);
|
||||
setForgotEmail('');
|
||||
setForgotError(null);
|
||||
setForgotMessage(null);
|
||||
setForgotLoading(false);
|
||||
}, []);
|
||||
|
||||
const openForgotModal = () => {
|
||||
setForgotOpen(true);
|
||||
setForgotError(null);
|
||||
setForgotMessage(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!forgotOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeForgotModal();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [forgotOpen, closeForgotModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('manual_login') !== '1' || manualLoginHandledRef.current) {
|
||||
return;
|
||||
}
|
||||
manualLoginHandledRef.current = true;
|
||||
|
||||
const finishPostResetLogout = async () => {
|
||||
setPwdResetBusy(true);
|
||||
try {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('frappe_session_id');
|
||||
|
||||
const csrfToken = await apiService.getCSRFTokenForGuest();
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (csrfToken) {
|
||||
headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
await fetch('/api/method/logout', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
}).catch(() => {
|
||||
// proceed even if logout fails
|
||||
});
|
||||
|
||||
sessionStorage.setItem(SESSION_STORAGE_FLAG_KEY, '1');
|
||||
navigate('/login', { replace: true });
|
||||
} finally {
|
||||
setPwdResetBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
finishPostResetLogout();
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem(SESSION_STORAGE_FLAG_KEY) === '1') {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_FLAG_KEY);
|
||||
setPostResetBanner(true);
|
||||
}
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pwdResetBusy) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('manual_login') === '1') return;
|
||||
|
||||
const user = localStorage.getItem('user');
|
||||
if (user) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
loadFrappeTranslations().catch(() => {
|
||||
// static translations only before login
|
||||
});
|
||||
}, [location.pathname, location.search, navigate, pwdResetBusy]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
@ -41,37 +138,33 @@ const Login: React.FC = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Dynamic import to catch any module loading errors
|
||||
const { useAuth } = await import('../hooks/useApi');
|
||||
const apiService = (await import('../services/apiService')).default;
|
||||
|
||||
const response = await apiService.login(formData);
|
||||
|
||||
|
||||
if (response && response.message) {
|
||||
const userData = {
|
||||
...response.message,
|
||||
email: formData.email
|
||||
email: formData.email,
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
|
||||
if (response.message.sid) {
|
||||
apiService.setSessionId(response.message.sid);
|
||||
}
|
||||
|
||||
// Load translations from Frappe after successful login
|
||||
|
||||
try {
|
||||
await loadFrappeTranslations();
|
||||
} catch (err) {
|
||||
console.warn('Could not load translations after login:', err);
|
||||
}
|
||||
|
||||
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError(t('login.loginFailed'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.message || t('login.loginFailed'));
|
||||
const message = err instanceof Error ? err.message : t('login.loginFailed');
|
||||
setError(message || t('login.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -82,35 +175,112 @@ const Login: React.FC = () => {
|
||||
full_name: 'Demo User',
|
||||
email: 'demo@seeraarabia.com',
|
||||
user_image: '',
|
||||
roles: ['System Manager', 'Administrator']
|
||||
roles: ['System Manager', 'Administrator'],
|
||||
};
|
||||
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(demoUser));
|
||||
|
||||
// Load translations from Frappe after demo login
|
||||
|
||||
try {
|
||||
await loadFrappeTranslations();
|
||||
} catch (err) {
|
||||
console.warn('Could not load translations after demo login:', err);
|
||||
}
|
||||
|
||||
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
const handleForgotSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = forgotEmail.trim();
|
||||
if (!trimmed) {
|
||||
setForgotError(t('login.forgotPasswordUserRequired'));
|
||||
setForgotMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
forgotAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
forgotAbortRef.current = controller;
|
||||
|
||||
setForgotLoading(true);
|
||||
setForgotError(null);
|
||||
setForgotMessage(null);
|
||||
|
||||
try {
|
||||
await apiService.requestPasswordReset(trimmed, controller.signal);
|
||||
setForgotMessage(t('login.forgotPasswordSentSuccess'));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === 'NOT_FOUND') {
|
||||
setForgotError(t('login.forgotPasswordNotFound'));
|
||||
} else if (err.code === 'CANNOT_RESET') {
|
||||
setForgotError(t('login.forgotPasswordCannotReset'));
|
||||
} else if (err.code === 'FORBIDDEN') {
|
||||
setForgotError(t('login.forgotPasswordFailed'));
|
||||
} else if (err.code === 'TIMEOUT') {
|
||||
setForgotError(t('login.forgotPasswordTimeout'));
|
||||
} else {
|
||||
setForgotError(t('login.forgotPasswordFailed'));
|
||||
}
|
||||
} else {
|
||||
setForgotError(t('login.forgotPasswordFailed'));
|
||||
}
|
||||
} finally {
|
||||
setForgotLoading(false);
|
||||
if (forgotAbortRef.current === controller) {
|
||||
forgotAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (pwdResetBusy) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t('login.finishingSignOut')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||||
<img
|
||||
<img
|
||||
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
|
||||
alt="Seera Arabia"
|
||||
alt="Seera Arabia"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
const container = e.currentTarget.parentElement;
|
||||
if (container) {
|
||||
container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||||
container.classList.add(
|
||||
'bg-gradient-to-br',
|
||||
'from-indigo-600',
|
||||
'to-purple-600'
|
||||
);
|
||||
}
|
||||
e.currentTarget.style.display = 'none';
|
||||
const nextSibling = e.currentTarget.nextElementSibling;
|
||||
@ -119,10 +289,28 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||||
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||||
<path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||
<svg
|
||||
className="w-20 h-20 hidden"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2L2 7L12 12L22 7L12 2Z"
|
||||
fill="white"
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M2 17L12 22L22 17V12L12 17L2 12V17Z"
|
||||
fill="white"
|
||||
fillOpacity="0.7"
|
||||
/>
|
||||
<path
|
||||
d="M12 12V17"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -136,8 +324,16 @@ const Login: React.FC = () => {
|
||||
{t('login.signIn')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{postResetBanner && (
|
||||
<div className="rounded-md bg-green-50 dark:bg-green-900/20 p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-300 text-center">
|
||||
{t('login.afterPasswordResetSignIn')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
@ -177,239 +373,157 @@ const Login: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
t('common.login')
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
) : (
|
||||
t('common.login')
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center -mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDemoLogin}
|
||||
className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={openForgotModal}
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
🚀 {t('login.demoLogin')}
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div className="hidden relative pt-2" aria-hidden="true">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDemoLogin}
|
||||
className="hidden w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
🚀 {t('login.demoLogin')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{forgotOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
onClick={closeForgotModal}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="forgot-password-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<h3
|
||||
id="forgot-password-title"
|
||||
className="text-xl font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForgotModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none shrink-0"
|
||||
aria-label={t('login.forgotPasswordClose')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('login.forgotPasswordHint')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleForgotSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder={t('login.forgotPasswordUserPlaceholder')}
|
||||
value={forgotEmail}
|
||||
onChange={(e) => {
|
||||
setForgotEmail(e.target.value);
|
||||
setForgotError(null);
|
||||
setForgotMessage(null);
|
||||
}}
|
||||
disabled={forgotLoading}
|
||||
/>
|
||||
|
||||
{forgotError && (
|
||||
<div className="mt-4 rounded-md bg-red-50 dark:bg-red-900/20 p-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{forgotError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{forgotMessage && (
|
||||
<div className="mt-4 rounded-md bg-green-50 dark:bg-green-900/20 p-3">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{forgotMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForgotModal}
|
||||
disabled={forgotLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{forgotLoading ? t('common.loading') : t('login.forgotPasswordSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
||||
|
||||
|
||||
// import React, { useState } from 'react';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
// import { useAuth } from '../hooks/useApi';
|
||||
|
||||
// interface LoginFormData {
|
||||
// email: string;
|
||||
// password: string;
|
||||
// }
|
||||
|
||||
// const Login: React.FC = () => {
|
||||
// const [formData, setFormData] = useState<LoginFormData>({
|
||||
// email: '',
|
||||
// password: '',
|
||||
// });
|
||||
// const [loading, setLoading] = useState(false);
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
// const navigate = useNavigate();
|
||||
// const { login } = useAuth();
|
||||
|
||||
// const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const { name, value } = e.target;
|
||||
// setFormData(prev => ({
|
||||
// ...prev,
|
||||
// [name]: value,
|
||||
// }));
|
||||
// setError(null);
|
||||
// };
|
||||
|
||||
// const handleSubmit = async (e: React.FormEvent) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(null);
|
||||
|
||||
// try {
|
||||
// const response = await login(formData);
|
||||
|
||||
// if (response && response.message) {
|
||||
// // Store user info in localStorage with email field
|
||||
// const userData = {
|
||||
// ...response.message,
|
||||
// email: formData.email // Ensure email is stored
|
||||
// };
|
||||
// localStorage.setItem('user', JSON.stringify(userData));
|
||||
// navigate('/dashboard');
|
||||
// } else {
|
||||
// setError('Login failed. Please check your credentials.');
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// setError(err.message || 'Login failed. Please try again.');
|
||||
// } finally {
|
||||
// setLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleDemoLogin = () => {
|
||||
// // Create dummy user data for demo purposes
|
||||
// const demoUser = {
|
||||
// full_name: 'Demo User',
|
||||
// email: 'demo@seeraarabia.com',
|
||||
// user_image: '',
|
||||
// roles: ['System Manager', 'Administrator']
|
||||
// };
|
||||
|
||||
// // Store demo user in localStorage
|
||||
// localStorage.setItem('user', JSON.stringify(demoUser));
|
||||
// navigate('/dashboard');
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
// <div className="max-w-md w-full space-y-8">
|
||||
// <div>
|
||||
// <div className="flex justify-center mb-6">
|
||||
// <div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||||
// {/* Seera Arabia Logo */}
|
||||
// <img
|
||||
// src="/seera-logo.png?v=1765198405"
|
||||
// alt="Seera Arabia"
|
||||
// className="w-full h-full object-contain"
|
||||
// onError={(e) => {
|
||||
// // Fallback to gradient background with SVG if image not found
|
||||
// const container = e.currentTarget.parentElement;
|
||||
// if (container) {
|
||||
// container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||||
// }
|
||||
// e.currentTarget.style.display = 'none';
|
||||
// e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
// }}
|
||||
// />
|
||||
// <svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
// <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||||
// <path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||||
// <path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||
// </svg>
|
||||
// </div>
|
||||
// </div>
|
||||
// <h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
|
||||
// Seera Arabia
|
||||
// </h2>
|
||||
// <p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||
// Asset Management System
|
||||
// </p>
|
||||
// <p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||||
// Sign in to continue
|
||||
// </p>
|
||||
// </div>
|
||||
|
||||
// <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
// <div className="rounded-md shadow-sm -space-y-px">
|
||||
// <div>
|
||||
// <label htmlFor="email" className="sr-only">
|
||||
// Email
|
||||
// </label>
|
||||
// <input
|
||||
// id="email"
|
||||
// name="email"
|
||||
// type="email"
|
||||
// required
|
||||
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
// placeholder="Email"
|
||||
// value={formData.email}
|
||||
// onChange={handleChange}
|
||||
// />
|
||||
// </div>
|
||||
// <div>
|
||||
// <label htmlFor="password" className="sr-only">
|
||||
// Password
|
||||
// </label>
|
||||
// <input
|
||||
// id="password"
|
||||
// name="password"
|
||||
// type="password"
|
||||
// required
|
||||
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
// placeholder="Password"
|
||||
// value={formData.password}
|
||||
// onChange={handleChange}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {error && (
|
||||
// <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
// <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// <div className="space-y-3">
|
||||
// <button
|
||||
// type="submit"
|
||||
// disabled={loading}
|
||||
// className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
// >
|
||||
// {loading ? (
|
||||
// <div className="flex items-center">
|
||||
// <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
// <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
// <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
// </svg>
|
||||
// Signing in...
|
||||
// </div>
|
||||
// ) : (
|
||||
// 'Sign in'
|
||||
// )}
|
||||
// </button>
|
||||
|
||||
// <div className="relative">
|
||||
// <div className="absolute inset-0 flex items-center">
|
||||
// <div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
// </div>
|
||||
// <div className="relative flex justify-center text-sm">
|
||||
// <span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={handleDemoLogin}
|
||||
// className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
// >
|
||||
// 🚀 {t('login.demoLogin')}
|
||||
// </button>
|
||||
// </div>
|
||||
// </form>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default Login;
|
||||
|
||||
@ -341,7 +341,7 @@ const MaintenanceTeamDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewTeam) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('maintenance.errorLoadingTeam')}</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
@ -352,11 +352,11 @@ const MaintenanceTeamDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
<ToastContainer position="top-right" autoClose={4000} hideProgressBar={false} newestOnTop closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover theme="colored" transition={Bounce} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/maintenance-teams')} className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<FaArrowLeft size={20} />
|
||||
|
||||
@ -330,7 +330,7 @@ const MaintenanceTeamList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Maintenance Teams</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
@ -341,9 +341,9 @@ const MaintenanceTeamList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaUsers className="text-3xl text-indigo-600 dark:text-indigo-400" />
|
||||
|
||||
@ -1,22 +1,163 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNumberCards, useDashboardChart } from '../hooks/useApi';
|
||||
import { useWorkOrders } from '../hooks/useWorkOrder';
|
||||
import { useAssetMaintenanceLogs } from '../hooks/useAssetMaintenance';
|
||||
import { useAssets } from '../hooks/useAsset';
|
||||
import type { Asset } from '../services/assetService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaShoppingCart, FaChartLine, FaBoxes, FaTools, FaCheckCircle, FaClock, FaExclamationTriangle, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
|
||||
const UP_TIME_COLOR = '#22C55E';
|
||||
const DOWN_TIME_COLOR = '#EF4444';
|
||||
|
||||
const parseHours = (value: unknown): number => {
|
||||
if (value == null || value === '') return 0;
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes(':')) {
|
||||
const parts = value.split(':').map(p => Number(p) || 0);
|
||||
if (parts.length === 3) return parts[0] + parts[1] / 60 + parts[2] / 3600;
|
||||
if (parts.length === 2) return parts[0] + parts[1] / 60;
|
||||
}
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** Match AssetDetail: derive up time from available_for_use_date when DB value is missing. */
|
||||
const getAssetUpDownHours = (asset: Asset): { up: number; down: number } => {
|
||||
const down = parseHours(asset.custom_down_time);
|
||||
let up = parseHours(asset.custom_up_time);
|
||||
|
||||
if (up === 0 && asset.available_for_use_date) {
|
||||
const availableDate = new Date(asset.available_for_use_date);
|
||||
if (!Number.isNaN(availableDate.getTime())) {
|
||||
const dayDiff = Math.floor(
|
||||
(Date.now() - availableDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const totalHours = dayDiff * 24;
|
||||
up = Math.max(0, totalHours - down);
|
||||
}
|
||||
}
|
||||
|
||||
return { up, down };
|
||||
};
|
||||
|
||||
const hasChartData = (data: unknown): boolean => {
|
||||
if (!data || typeof data !== 'object' || 'error' in (data as object)) return false;
|
||||
const chart = data as { datasets?: { values?: unknown[] }[] };
|
||||
const values = chart.datasets?.[0]?.values;
|
||||
if (!Array.isArray(values) || values.length === 0) return false;
|
||||
return values.reduce((sum, v) => sum + parseHours(v), 0) > 0;
|
||||
};
|
||||
|
||||
type PieChartPayload = {
|
||||
labels: string[];
|
||||
datasets: { name: string; values: number[]; colors: string[] }[];
|
||||
type: 'Pie';
|
||||
};
|
||||
|
||||
const buildUpDownPie = (
|
||||
upValue: number,
|
||||
downValue: number,
|
||||
upLabel: string,
|
||||
downLabel: string
|
||||
): PieChartPayload | null => {
|
||||
const labels: string[] = [];
|
||||
const values: number[] = [];
|
||||
const colors: string[] = [];
|
||||
|
||||
if (upValue > 0) {
|
||||
labels.push(upLabel);
|
||||
values.push(upValue);
|
||||
colors.push(UP_TIME_COLOR);
|
||||
}
|
||||
if (downValue > 0) {
|
||||
labels.push(downLabel);
|
||||
values.push(downValue);
|
||||
colors.push(DOWN_TIME_COLOR);
|
||||
}
|
||||
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{ name: 'Assets', values, colors }],
|
||||
type: 'Pie',
|
||||
};
|
||||
};
|
||||
|
||||
const countAssetsByDeviceStatus = (assetList: Asset[]): { upCount: number; downCount: number } => {
|
||||
let upCount = 0;
|
||||
let downCount = 0;
|
||||
|
||||
assetList.forEach(asset => {
|
||||
const status = (asset.custom_device_status || '').trim().toLowerCase();
|
||||
if (status === 'up') upCount += 1;
|
||||
else if (status === 'down') downCount += 1;
|
||||
});
|
||||
|
||||
return { upCount, downCount };
|
||||
};
|
||||
|
||||
const sumAssetUpDownHours = (assetList: Asset[]): { totalUp: number; totalDown: number } => {
|
||||
let totalUp = 0;
|
||||
let totalDown = 0;
|
||||
|
||||
assetList.forEach(asset => {
|
||||
const { up, down } = getAssetUpDownHours(asset);
|
||||
totalUp += up;
|
||||
totalDown += down;
|
||||
});
|
||||
|
||||
return { totalUp, totalDown };
|
||||
};
|
||||
|
||||
const normalizeUpDownApiChart = (
|
||||
apiData: unknown,
|
||||
upLabel: string,
|
||||
downLabel: string
|
||||
): PieChartPayload | null => {
|
||||
if (!hasChartData(apiData)) return null;
|
||||
|
||||
const chart = apiData as { labels?: string[]; datasets?: { values?: unknown[] }[] };
|
||||
let upValue = 0;
|
||||
let downValue = 0;
|
||||
|
||||
(chart.labels || []).forEach((label, i) => {
|
||||
const val = parseHours(chart.datasets?.[0]?.values?.[i]);
|
||||
if (val <= 0) return;
|
||||
|
||||
const normalized = String(label).toLowerCase().trim();
|
||||
const isDown =
|
||||
normalized === 'down' ||
|
||||
normalized.includes('down time') ||
|
||||
normalized.includes('downtime');
|
||||
const isUp =
|
||||
normalized === 'up' ||
|
||||
normalized.includes('up time') ||
|
||||
normalized.includes('uptime');
|
||||
|
||||
if (isDown) downValue += val;
|
||||
else if (isUp) upValue += val;
|
||||
});
|
||||
|
||||
return buildUpDownPie(upValue, downValue, upLabel, downLabel);
|
||||
};
|
||||
|
||||
const ModernDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: numberCards, loading: cardsLoading } = useNumberCards();
|
||||
const { workOrders } = useWorkOrders({}, 1000, 0);
|
||||
const { logs: maintenanceLogs } = useAssetMaintenanceLogs({}, 1000, 0);
|
||||
const { assets } = useAssets({}, 1000, 0);
|
||||
const [chartAssets, setChartAssets] = useState<Asset[]>([]);
|
||||
const [chartAssetsLoading, setChartAssetsLoading] = useState(true);
|
||||
|
||||
const [workOrderChartData, setWorkOrderChartData] = useState<any>(null);
|
||||
const [workOrderGroupedChartData, setWorkOrderGroupedChartData] = useState<any>(null);
|
||||
const [maintenanceAssetChartData, setMaintenanceAssetChartData] = useState<any>(null);
|
||||
const [upDownTimeChartData, setUpDownTimeChartData] = useState<any>(null);
|
||||
const [assigneesGroupedChartData, setAssigneesGroupedChartData] = useState<any>(null);
|
||||
const [maintenanceFrequencyChartData, setMaintenanceFrequencyChartData] = useState<any>(null);
|
||||
|
||||
@ -43,58 +184,69 @@ const ModernDashboard: React.FC = () => {
|
||||
const { data: frequencyChart } = useDashboardChart('Asset Maintenance Frequency Chart');
|
||||
// const { data: ppmStatusChart } = useDashboardChart('PPM Status');
|
||||
|
||||
// Generate Up & Down Time Chart data from assets
|
||||
// Lightweight asset fetch for Up/Down chart only (minimal fields, no finance books)
|
||||
useEffect(() => {
|
||||
if (assets && assets.length > 0) {
|
||||
let totalUpTime = 0;
|
||||
let totalDownTime = 0;
|
||||
|
||||
assets.forEach(asset => {
|
||||
// Sum up time and down time values
|
||||
const upTime = asset.custom_up_time || 0;
|
||||
const downTime = asset.custom_down_time || 0;
|
||||
|
||||
totalUpTime += typeof upTime === 'number' ? upTime : 0;
|
||||
totalDownTime += typeof downTime === 'number' ? downTime : 0;
|
||||
});
|
||||
let cancelled = false;
|
||||
|
||||
// Create pie chart data
|
||||
const labels: string[] = [];
|
||||
const values: number[] = [];
|
||||
|
||||
if (totalUpTime > 0) {
|
||||
labels.push(t('dashboard.upTime'));
|
||||
values.push(totalUpTime);
|
||||
}
|
||||
|
||||
if (totalDownTime > 0) {
|
||||
labels.push(t('dashboard.downTime'));
|
||||
values.push(totalDownTime);
|
||||
const fetchChartAssets = async () => {
|
||||
setChartAssetsLoading(true);
|
||||
try {
|
||||
const fields = JSON.stringify([
|
||||
'name',
|
||||
'custom_device_status',
|
||||
'custom_up_time',
|
||||
'custom_down_time',
|
||||
'available_for_use_date',
|
||||
]);
|
||||
const response = await fetch(
|
||||
`/api/resource/Asset?fields=${encodeURIComponent(fields)}&limit_page_length=5000`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error(`Asset fetch failed: ${response.status}`);
|
||||
const json = await response.json();
|
||||
if (!cancelled) setChartAssets(json.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets for uptime chart:', err);
|
||||
if (!cancelled) setChartAssets([]);
|
||||
} finally {
|
||||
if (!cancelled) setChartAssetsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If we have data, create the chart
|
||||
if (labels.length > 0 && values.length > 0) {
|
||||
// Use blue and purple color palette
|
||||
const pieColors: string[] = [];
|
||||
if (totalUpTime > 0) pieColors.push('#6366F1'); // Indigo for Up Time
|
||||
if (totalDownTime > 0) pieColors.push('#8B5CF6'); // Purple for Down Time
|
||||
|
||||
setUpDownTimeChartData({
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
name: 'Time',
|
||||
values: values,
|
||||
colors: pieColors
|
||||
}],
|
||||
type: 'Pie'
|
||||
});
|
||||
} else {
|
||||
setUpDownTimeChartData(null);
|
||||
}
|
||||
} else {
|
||||
setUpDownTimeChartData(null);
|
||||
fetchChartAssets();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const upTimeLabel = t('dashboard.upTime');
|
||||
const downTimeLabel = t('dashboard.downTime');
|
||||
|
||||
const assetUpDownChart = useMemo((): PieChartPayload | null => {
|
||||
if (!chartAssets.length) return null;
|
||||
|
||||
const { upCount, downCount } = countAssetsByDeviceStatus(chartAssets);
|
||||
if (upCount > 0 || downCount > 0) {
|
||||
return buildUpDownPie(upCount, downCount, upTimeLabel, downTimeLabel);
|
||||
}
|
||||
}, [assets]);
|
||||
|
||||
const { totalUp, totalDown } = sumAssetUpDownHours(chartAssets);
|
||||
if (totalUp > 0 || totalDown > 0) {
|
||||
return buildUpDownPie(totalUp, totalDown, upTimeLabel, downTimeLabel);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [chartAssets, upTimeLabel, downTimeLabel]);
|
||||
|
||||
const apiUpDownChart = useMemo(
|
||||
() => normalizeUpDownApiChart(upDownChart, upTimeLabel, downTimeLabel),
|
||||
[upDownChart, upTimeLabel, downTimeLabel]
|
||||
);
|
||||
|
||||
const resolvedUpDownChart = assetUpDownChart ?? apiUpDownChart;
|
||||
const upDownChartLoading = !resolvedUpDownChart && chartAssetsLoading;
|
||||
|
||||
// Generate Work Order Status Chart data and counts
|
||||
useEffect(() => {
|
||||
@ -551,7 +703,8 @@ const ModernDashboard: React.FC = () => {
|
||||
{/* Up & Down Time - Pie Chart */}
|
||||
<div className="w-full">
|
||||
<CustomerSatisfactionCard
|
||||
data={upDownTimeChartData || upDownChart}
|
||||
data={resolvedUpDownChart}
|
||||
loading={upDownChartLoading}
|
||||
title={t('dashboard.upDownTimeChart')}
|
||||
description="Asset uptime and downtime distribution for tracking availability."
|
||||
/>
|
||||
@ -748,16 +901,25 @@ const DepartmentSalesCard: React.FC<{ data: any; totalWorkOrders: number; comple
|
||||
};
|
||||
|
||||
// Pie Chart Card with Custom Title (Compact Version)
|
||||
const CustomerSatisfactionCard: React.FC<{ data: any; title: string; description: string }> = ({
|
||||
data, title, description
|
||||
}) => {
|
||||
const CustomerSatisfactionCard: React.FC<{
|
||||
data: any;
|
||||
title: string;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
}> = ({ data, title, description, loading }) => {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all p-5 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">{title}</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
{description}
|
||||
</p>
|
||||
{data ? <PieChart data={data} /> : (
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center text-gray-400 text-sm">
|
||||
Loading chart data...
|
||||
</div>
|
||||
) : hasChartData(data) ? (
|
||||
<PieChart data={data} />
|
||||
) : (
|
||||
<div className="h-64 flex items-center justify-center text-gray-400">
|
||||
No data available
|
||||
</div>
|
||||
@ -826,8 +988,61 @@ const MiniAreaChart: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// --- Chart hover tooltips (inline) ---
|
||||
interface ChartTooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const formatChartTooltip = (label: string, value: number, total?: number): string => {
|
||||
if (total != null && total > 0) {
|
||||
const pct = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} (${pct}%)`;
|
||||
}
|
||||
return `${label}: ${value}`;
|
||||
};
|
||||
|
||||
const useChartTooltip = () => {
|
||||
const [tooltip, setTooltip] = useState<ChartTooltipState | null>(null);
|
||||
|
||||
const show = useCallback(
|
||||
(clientX: number, clientY: number, label: string, value: number, total?: number) => {
|
||||
setTooltip({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
text: formatChartTooltip(label, value, total),
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const hide = useCallback(() => setTooltip(null), []);
|
||||
|
||||
return { tooltip, show, hide };
|
||||
};
|
||||
|
||||
const ChartTooltipOverlay: React.FC<{ tooltip: ChartTooltipState | null }> = ({ tooltip }) => {
|
||||
if (!tooltip) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none px-2.5 py-1.5 rounded text-xs font-medium text-white bg-gray-900 shadow-lg whitespace-nowrap"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 8,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Grouped Bar Chart Component for multiple datasets
|
||||
const GroupedBarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
const { tooltip, show, hide } = useChartTooltip();
|
||||
const labels = data?.labels || [];
|
||||
const datasets = data?.datasets || [];
|
||||
|
||||
@ -871,8 +1086,13 @@ const GroupedBarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
return generateColors(numBars)[index];
|
||||
};
|
||||
|
||||
const groupTotals = labels.map((_: string, labelIndex: number) =>
|
||||
datasets.reduce((sum: number, dataset: any) => sum + parseHours(dataset.values?.[labelIndex]), 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<div className="relative w-full overflow-x-auto" onMouseLeave={hide}>
|
||||
<ChartTooltipOverlay tooltip={tooltip} />
|
||||
<svg width="100%" height={chartHeight + 40} viewBox={`0 0 ${width} ${chartHeight + 40}`} className="w-full" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
{datasets.map((ds: any, i: number) => {
|
||||
@ -931,11 +1151,13 @@ const GroupedBarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
return (
|
||||
<g key={labelIndex}>
|
||||
{datasets.map((dataset: any, dsIndex: number) => {
|
||||
const value = dataset.values?.[labelIndex] || 0;
|
||||
const value = parseHours(dataset.values?.[labelIndex]);
|
||||
const barHeight = (value / max) * chartHeight;
|
||||
const x = groupX + barSpacing + (dsIndex * (barWidth + barSpacing));
|
||||
const y = chartHeight - barHeight;
|
||||
const color = getDatasetColor(dataset.name || '', dsIndex);
|
||||
const seriesLabel = dataset.name || `Series ${dsIndex + 1}`;
|
||||
const tooltipLabel = labels.length > 1 ? `${label} — ${seriesLabel}` : seriesLabel;
|
||||
|
||||
return (
|
||||
<g key={dsIndex}>
|
||||
@ -948,6 +1170,9 @@ const GroupedBarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
rx="4"
|
||||
ry="4"
|
||||
className="hover:opacity-80 cursor-pointer transition-opacity"
|
||||
onMouseEnter={(e) => show(e.clientX, e.clientY, tooltipLabel, value, groupTotals[labelIndex])}
|
||||
onMouseMove={(e) => show(e.clientX, e.clientY, tooltipLabel, value, groupTotals[labelIndex])}
|
||||
onMouseLeave={hide}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@ -987,6 +1212,7 @@ const GroupedBarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
|
||||
// Bar Chart Component with Line Overlay (Like in Image)
|
||||
const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
const { tooltip, show, hide } = useChartTooltip();
|
||||
const labels = data?.labels || [];
|
||||
const datasets = data?.datasets || [];
|
||||
|
||||
@ -1003,10 +1229,12 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
const width = calculatedWidth;
|
||||
|
||||
// Generate smooth line data (Average line overlay)
|
||||
const lineData = datasets[0]?.values || [];
|
||||
const lineData = (datasets[0]?.values || []).map((v: unknown) => parseHours(v));
|
||||
const barTotal = lineData.reduce((sum: number, v: number) => sum + v, 0);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<div className="relative w-full overflow-x-auto" onMouseLeave={hide}>
|
||||
<ChartTooltipOverlay tooltip={tooltip} />
|
||||
<svg width="100%" height="320" viewBox={`0 0 ${width} ${chartHeight + 70}`} className="w-full" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
@ -1043,8 +1271,8 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
))}
|
||||
|
||||
{/* Bars */}
|
||||
{labels.map((_label: string, i: number) => {
|
||||
const value = datasets[0]?.values?.[i] || 0;
|
||||
{labels.map((label: string, i: number) => {
|
||||
const value = parseHours(datasets[0]?.values?.[i]);
|
||||
const barHeight = (value / max) * chartHeight;
|
||||
const barWidth = Math.min(40, (width - 100) / labels.length - 10);
|
||||
const x = 80 + (i * ((width - 100) / labels.length));
|
||||
@ -1060,6 +1288,9 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
rx="4"
|
||||
ry="4"
|
||||
className="hover:opacity-80 cursor-pointer transition-opacity"
|
||||
onMouseEnter={(e) => show(e.clientX, e.clientY, label || 'Value', value, barTotal)}
|
||||
onMouseMove={(e) => show(e.clientX, e.clientY, label || 'Value', value, barTotal)}
|
||||
onMouseLeave={hide}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@ -1086,6 +1317,7 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
{lineData.map((value: number, i: number) => {
|
||||
const x = 80 + (i * ((width - 100) / labels.length)) + 20;
|
||||
const y = chartHeight - ((value / max) * chartHeight);
|
||||
const pointLabel = labels[i] || 'Value';
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
@ -1094,6 +1326,9 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
r="4"
|
||||
fill="#3B82F6"
|
||||
className="hover:r-6 cursor-pointer transition-all"
|
||||
onMouseEnter={(e) => show(e.clientX, e.clientY, pointLabel, value, barTotal)}
|
||||
onMouseMove={(e) => show(e.clientX, e.clientY, pointLabel, value, barTotal)}
|
||||
onMouseLeave={hide}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -1136,33 +1371,26 @@ const BarChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
|
||||
// Pie Chart Component
|
||||
const PieChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
const { tooltip, show, hide } = useChartTooltip();
|
||||
const labels = data?.labels || [];
|
||||
const values = data?.datasets?.[0]?.values || [];
|
||||
const values = (data?.datasets?.[0]?.values || []).map((v: unknown) => parseHours(v));
|
||||
|
||||
// Check if this is an Up & Down Time chart and ALWAYS apply blue/purple colors
|
||||
const isUpDownTimeChart = labels.some((label: string) =>
|
||||
label.toLowerCase().includes('up time') ||
|
||||
label.toLowerCase().includes('down time') ||
|
||||
label.toLowerCase().includes('uptime') ||
|
||||
label.toLowerCase().includes('downtime')
|
||||
);
|
||||
const isUpDownLabel = (label: string) => {
|
||||
const l = label.toLowerCase();
|
||||
return l.includes('up') || l.includes('down') || l.includes('تشغيل') || l.includes('توقف');
|
||||
};
|
||||
|
||||
const isUpDownTimeChart = labels.some(isUpDownLabel);
|
||||
|
||||
let colors: string[] = [];
|
||||
|
||||
// Always override colors for Up & Down Time charts to use blue/purple palette
|
||||
if (isUpDownTimeChart && labels.length >= 1) {
|
||||
// Apply blue/purple colors based on label order and content
|
||||
colors = labels.map((label: string) => {
|
||||
const labelLower = label.toLowerCase();
|
||||
if (labelLower.includes('up time') || labelLower.includes('uptime')) {
|
||||
return '#6366F1'; // Indigo for Up Time
|
||||
}
|
||||
if (labelLower.includes('down time') || labelLower.includes('downtime')) {
|
||||
return '#8B5CF6'; // Purple for Down Time
|
||||
}
|
||||
// If label doesn't match, assign based on position (first = up, second = down)
|
||||
if (labelLower.includes('down') || label.includes('توقف')) return DOWN_TIME_COLOR;
|
||||
if (labelLower.includes('up') || label.includes('تشغيل')) return UP_TIME_COLOR;
|
||||
const index = labels.indexOf(label);
|
||||
return index === 0 ? '#6366F1' : '#8B5CF6';
|
||||
return index === 0 ? UP_TIME_COLOR : DOWN_TIME_COLOR;
|
||||
});
|
||||
} else {
|
||||
// For other charts, use custom colors if provided, otherwise generate
|
||||
@ -1173,6 +1401,15 @@ const PieChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
}
|
||||
|
||||
const total = values.reduce((sum: number, val: number) => sum + val, 0);
|
||||
|
||||
if (total <= 0 || labels.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 text-sm">
|
||||
No data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const radius = 100;
|
||||
const cx = radius + 10;
|
||||
const cy = radius + 10;
|
||||
@ -1199,20 +1436,30 @@ const PieChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-center justify-around">
|
||||
<div className="flex flex-col md:flex-row items-center justify-around" onMouseLeave={hide}>
|
||||
<ChartTooltipOverlay tooltip={tooltip} />
|
||||
<svg width={cx * 2} height={cy * 2} viewBox={`0 0 ${cx * 2} ${cy * 2}`} className="max-w-xs">
|
||||
{slices.map((slice: any, i: number) => (
|
||||
<path
|
||||
key={i}
|
||||
d={slice.path}
|
||||
fill={slice.color}
|
||||
className="hover:opacity-80 transition-opacity cursor-pointer drop-shadow-lg"
|
||||
className="hover:opacity-90 transition-opacity cursor-pointer drop-shadow-lg"
|
||||
onMouseEnter={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
|
||||
onMouseMove={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
|
||||
onMouseLeave={hide}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex flex-col gap-3 mt-4 md:mt-0">
|
||||
{slices.map((slice: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 cursor-pointer rounded px-1 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
onMouseEnter={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
|
||||
onMouseMove={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: slice.color }}></div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{slice.label}</span>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white">{slice.value}</span>
|
||||
|
||||
@ -101,7 +101,7 @@ const PPMDetail: React.FC = () => {
|
||||
|
||||
if (error && !isNewPPM && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
@ -116,9 +116,9 @@ const PPMDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/ppm')}
|
||||
|
||||
@ -117,7 +117,7 @@ const PPMList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ PPM API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
@ -155,11 +155,11 @@ const PPMList: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('ppm.title')}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('ppm.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} PPM schedule{totalCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
@ -434,10 +434,9 @@ const PPMPlanner: React.FC = () => {
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
<option value="Under Maintenance">Under Maintenance</option>
|
||||
<option value="Decommissioned">Decommissioned</option>
|
||||
<option value="Up">Up</option>
|
||||
<option value="Down">Down</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -139,8 +139,11 @@ const UserProfilePage: React.FC = () => {
|
||||
first_name: formData.first_name,
|
||||
middle_name: formData.middle_name,
|
||||
last_name: formData.last_name,
|
||||
role_profile_name: formData.role_profile_name,
|
||||
};
|
||||
|
||||
if (isSystemManager) {
|
||||
updateData.role_profile_name = formData.role_profile_name;
|
||||
}
|
||||
|
||||
const updatedProfile = await updateUserProfile(profile.email, updateData);
|
||||
setProfile(prev => prev ? { ...prev, ...updatedProfile } : updatedProfile);
|
||||
@ -498,16 +501,31 @@ const UserProfilePage: React.FC = () => {
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* Role Profile - Only System Manager can edit */}
|
||||
{/* Role Profile — editable only for System Manager */}
|
||||
<div>
|
||||
<LinkField
|
||||
label="Role Profile"
|
||||
doctype="Role Profile"
|
||||
value={formData.role_profile_name}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||
placeholder="Select Role Profile"
|
||||
disabled={!isSystemManager}
|
||||
/>
|
||||
{isSystemManager ? (
|
||||
<LinkField
|
||||
label="Role Profile"
|
||||
doctype="Role Profile"
|
||||
value={formData.role_profile_name}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||
placeholder="Select Role Profile"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Role Profile
|
||||
<span className="ml-1 text-gray-400">(Read-only)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.role_profile_name || '-'}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -63,7 +63,7 @@ const UsersList: React.FC = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Users</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white break-words">Users</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
|
||||
@ -864,7 +864,7 @@ useEffect(() => {
|
||||
|
||||
if (error && !isNewWorkOrder && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
@ -902,7 +902,7 @@ const canEditBasedOnWorkflow =
|
||||
(!workflowLoading && transitions.length > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
|
||||
{/* Toast Container for notifications */}
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
@ -919,7 +919,7 @@ const canEditBasedOnWorkflow =
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/work-orders')}
|
||||
|
||||
@ -883,7 +883,7 @@ const WorkOrderList: React.FC = () => {
|
||||
// Error state for permissions
|
||||
if (permissionsError) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">⚠️ Permission Error</h2>
|
||||
<div className="text-red-700 dark:text-red-400 space-y-3">
|
||||
@ -915,7 +915,7 @@ const WorkOrderList: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ Work Order API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
@ -939,11 +939,11 @@ const WorkOrderList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('workOrders.title')}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('workOrders.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} work order{totalCount !== 1 ? 's' : ''}
|
||||
{selectedRows.size > 0 && (
|
||||
@ -1672,7 +1672,7 @@ const WorkOrderList: React.FC = () => {
|
||||
|
||||
{actionMenuOpen === workOrder.name && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => {
|
||||
handleExportSingle(workOrder);
|
||||
setActionMenuOpen(null);
|
||||
@ -1681,7 +1681,7 @@ const WorkOrderList: React.FC = () => {
|
||||
>
|
||||
<FaDownload className="text-blue-500" />
|
||||
Export as JSON
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handlePrint(workOrder.name);
|
||||
|
||||
@ -177,6 +177,102 @@ class ApiService {
|
||||
this.timeout = API_CONFIG.TIMEOUT;
|
||||
}
|
||||
|
||||
// CSRF for guest requests (forgot password, post-reset logout)
|
||||
async getCSRFTokenForGuest(): Promise<string | null> {
|
||||
if (typeof window !== 'undefined' && (window as any).csrf_token) {
|
||||
return (window as any).csrf_token;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data: ApiResponse<string> = await response.json();
|
||||
return data.message || null;
|
||||
}
|
||||
} catch {
|
||||
// guest CSRF is optional for some sites
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestPasswordReset(user: string, signal?: AbortSignal): Promise<void> {
|
||||
const trimmed = user.trim();
|
||||
if (!trimmed) {
|
||||
throw new ApiError('User required', undefined, 'USER_REQUIRED');
|
||||
}
|
||||
|
||||
const csrfToken = await this.getCSRFTokenForGuest();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (csrfToken) {
|
||||
headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
||||
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timeoutId);
|
||||
throw new ApiError('Aborted', undefined, 'TIMEOUT');
|
||||
}
|
||||
signal.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${this.endpoints.RESET_PASSWORD}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: `user=${encodeURIComponent(trimmed)}`,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data: ApiResponse = await response.json().catch(() => ({}));
|
||||
const message =
|
||||
typeof data.message === 'string'
|
||||
? data.message
|
||||
: typeof data.message === 'object' && data.message !== null
|
||||
? String((data.message as { message?: string }).message ?? '')
|
||||
: '';
|
||||
|
||||
if (response.status === 404 || message === 'not found' || /not found/i.test(message)) {
|
||||
throw new ApiError('not found', 404, 'NOT_FOUND');
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ApiError('forbidden', 403, 'FORBIDDEN');
|
||||
}
|
||||
if (message === 'disabled' || message === 'not allowed') {
|
||||
throw new ApiError(message, 200, 'CANNOT_RESET');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(data.error || `HTTP error! status: ${response.status}`, response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new ApiError('timeout', undefined, 'TIMEOUT');
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ApiError('timeout', undefined, 'TIMEOUT');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get CSRF Token for authenticated requests
|
||||
async getCSRFToken(): Promise<string | null> {
|
||||
try {
|
||||
|
||||
49
asm_app/src/utils/qrNavigation.ts
Normal file
49
asm_app/src/utils/qrNavigation.ts
Normal file
@ -0,0 +1,49 @@
|
||||
const APP_BASENAME = '/asm_app';
|
||||
|
||||
/**
|
||||
* Convert a scanned QR payload into an in-app React Router path (without basename).
|
||||
* Examples:
|
||||
* http://host/asm_app/assets/ACC-ASS-2026-00002 → /assets/ACC-ASS-2026-00002
|
||||
* /asm_app/assets/ACC-ASS-2026-00002 → /assets/ACC-ASS-2026-00002
|
||||
* /assets/ACC-ASS-2026-00002 → /assets/ACC-ASS-2026-00002
|
||||
* ACC-ASS-2026-00002 → /assets/ACC-ASS-2026-00002
|
||||
*/
|
||||
export function parseQrPayloadToAppPath(raw: string): string | null {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
|
||||
let path = text;
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(text)) {
|
||||
const url = new URL(text);
|
||||
path = url.pathname + url.search + url.hash;
|
||||
}
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
// Strip app basename so react-router can navigate inside the SPA
|
||||
if (path.startsWith(APP_BASENAME + '/') || path === APP_BASENAME) {
|
||||
path = path.slice(APP_BASENAME.length) || '/';
|
||||
}
|
||||
|
||||
// Absolute app-relative path already (e.g. /assets/...)
|
||||
if (path.startsWith('/')) {
|
||||
return path.split('?')[0] || path;
|
||||
}
|
||||
|
||||
// Bare asset / work order id
|
||||
if (/^(ACC-ASS-|AST-|ACC-WO-)/i.test(path) || /^[A-Z0-9-]+$/i.test(path)) {
|
||||
if (/^(ACC-WO-|WO-)/i.test(path)) {
|
||||
return `/work-orders/${path}`;
|
||||
}
|
||||
return `/assets/${path}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isAssetAppPath(path: string): boolean {
|
||||
return /^\/assets\/[^/]+$/i.test(path.split('?')[0]);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
asm_ui_app/public/asm_app/assets/index-DpwoT1cm.css
Normal file
1
asm_ui_app/public/asm_app/assets/index-DpwoT1cm.css
Normal file
File diff suppressed because one or more lines are too long
1740
asm_ui_app/public/asm_app/assets/index-rQSfSDmu.js
Normal file
1740
asm_ui_app/public/asm_app/assets/index-rQSfSDmu.js
Normal file
File diff suppressed because one or more lines are too long
@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Seera Arabia Asset Management System" />
|
||||
<title>Seera Arabia - Asset Management System</title>
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-BIWpBVtY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BOZnpaxf.css">
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-rQSfSDmu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-DpwoT1cm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Seera Arabia Asset Management System" />
|
||||
<title>Seera Arabia - Asset Management System</title>
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-BIWpBVtY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BOZnpaxf.css">
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-rQSfSDmu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-DpwoT1cm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
333
asm_ui_app/www/update-password.html
Normal file
333
asm_ui_app/www/update-password.html
Normal file
@ -0,0 +1,333 @@
|
||||
{% extends "templates/web.html" %}
|
||||
|
||||
{% block title %} {{_("Reset Password")}} {% endblock %}
|
||||
{% block head_include %}
|
||||
{% endblock %}
|
||||
{% block page_content %}
|
||||
<section class="for-reset-password d-block">
|
||||
<div class="page-card-head">
|
||||
<h4 class="reset-password-heading">{{ _("Reset Password") if frappe.db.get_default('company') else _("Set Password")}}</h4>
|
||||
</div>
|
||||
<div class="page-card">
|
||||
<form id="reset-password">
|
||||
<div class="form-group">
|
||||
<input id="old_password" type="password"
|
||||
class="form-control mb-4" placeholder="{{ _('Old Password') }}" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input id="new_password" type="password"
|
||||
class="form-control mb-4" placeholder="{{ _('New Password') }}" autocomplete="new-password">
|
||||
<span class="password-strength-indicator indicator"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input id="confirm_password" type="password"
|
||||
class="form-control" placeholder="{{ _('Confirm Password') }}" autocomplete="new-password">
|
||||
|
||||
</div>
|
||||
<p class="password-mismatch-message text-muted small hidden mt-2"></p>
|
||||
<p class='password-strength-message text-muted small mt-2 hidden'></p>
|
||||
<button type="submit" id="update" disabled = true style="cursor: not-allowed;"
|
||||
class="btn btn-primary btn-block btn-update">{{_("Confirm")}}</button>
|
||||
</form>
|
||||
{%- if not disable_signup -%}
|
||||
<div class="text-center sign-up-message">
|
||||
{{ _("Don't have an account?") }}
|
||||
<a href="/login#signup">{{ _("Sign up") }}</a>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.page-card-head {
|
||||
padding: max(5vh, 30px) 0 14px 0px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
.page-card-head img {
|
||||
max-height: 42px;
|
||||
}
|
||||
|
||||
.page-card-head h4 {
|
||||
margin-top: 1rem;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
frappe.ready(function() {
|
||||
// URL args
|
||||
const key = frappe.utils.get_url_arg('key');
|
||||
const password_expired = frappe.utils.get_url_arg('password_expired');
|
||||
// inputs, paragraphs and button elements
|
||||
const old_password = $('#old_password');
|
||||
const new_password = $('#new_password');
|
||||
const confirm_password = $('#confirm_password');
|
||||
const update_button = $('#update');
|
||||
const password_strength_indicator = $('.password-strength-indicator');
|
||||
const password_strength_message =$('.password-strength-message');
|
||||
const password_mismatch_message = $('.password-mismatch-message');
|
||||
// Info text
|
||||
const password_not_same_as_old_password = "{{ _('New password cannot be same as old password') }}";
|
||||
const password_mismatch = "{{ _('Passwords do not match') }}";
|
||||
const password_strength_message_success = "{{ _('Success! You are good to go 👍') }}";
|
||||
|
||||
if(key) {
|
||||
old_password.parent().toggle();
|
||||
}
|
||||
|
||||
if(password_expired) {
|
||||
$(".password-box").html("{{ _('The password of your account has expired.') }}");
|
||||
}
|
||||
|
||||
$("#reset-password").on("submit", function() {
|
||||
return false;
|
||||
});
|
||||
|
||||
new_password.on("keypress", function(e) {
|
||||
if(e.which===13) update_button.click();
|
||||
})
|
||||
|
||||
update_button.click(function() {
|
||||
var args = {
|
||||
key: key || "",
|
||||
old_password: old_password.val(),
|
||||
new_password: new_password.val(),
|
||||
confirm_password: confirm_password.val(),
|
||||
logout_all_sessions: 1
|
||||
}
|
||||
if (!args.old_password && !args.key) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Missing Value') }}",
|
||||
message: "{{ _('Please enter your old password.') }}",
|
||||
clear: true
|
||||
});
|
||||
}
|
||||
if (!args.new_password) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Missing Value') }}",
|
||||
message: "{{ _('Please enter your new password.') }}",
|
||||
clear: true
|
||||
});
|
||||
}
|
||||
if (args.old_password === args.new_password) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Invalid Password') }}",
|
||||
message: password_not_same_as_old_password,
|
||||
});
|
||||
password_strength_message.addClass('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.new_password !== args.confirm_password) {
|
||||
password_mismatch_message.text(password_mismatch)
|
||||
.removeClass('hidden text-muted').addClass('text-danger');
|
||||
password_strength_message.addClass('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
type: "POST",
|
||||
method: "frappe.core.doctype.user.user.update_password",
|
||||
btn: update_button,
|
||||
args: args,
|
||||
statusCode: {
|
||||
401: function() {
|
||||
$(".page-card-head .reset-password-heading").text("{{ _('Invalid Password') }}");
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Invalid Password') }}",
|
||||
message: "{{ _('Your old password is incorrect.') }}",
|
||||
clear: true
|
||||
});
|
||||
},
|
||||
410: function({ responseJSON }) {
|
||||
const title = "{{ _('Invalid Link') }}";
|
||||
const message = responseJSON.message;
|
||||
$(".page-card-head .reset-password-heading").text(title);
|
||||
frappe.msgprint({ title: title, message: message, clear: true });
|
||||
},
|
||||
200: function(r) {
|
||||
$("input").val("");
|
||||
strength_indicator.addClass("hidden");
|
||||
strength_message.addClass("hidden");
|
||||
$(".page-card-head .reset-password-heading")
|
||||
.html("{{ _('Status Updated') }}");
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Password set') }}",
|
||||
message: "{{ _('Your new password has been set successfully.') }}",
|
||||
clear: true
|
||||
});
|
||||
setTimeout(function() {
|
||||
window.location.href = "{{ frappe.utils.get_url('/asm_app/login?manual_login=1') }}";
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
window.strength_indicator = password_strength_indicator;
|
||||
window.strength_message = password_strength_message;
|
||||
|
||||
new_password.on('keyup', function() {
|
||||
window.clear_timeout();
|
||||
window.timout_password_strength = setTimeout(window.test_password_strength, 200);
|
||||
});
|
||||
|
||||
$("#old_password, #new_password, #confirm_password").on("keyup paste", frappe.utils.debounce(function () {
|
||||
let common_conditions = new_password.val() && confirm_password.val() && new_password.val() === confirm_password.val()
|
||||
|
||||
if (new_password.val() && old_password.val() === new_password.val()) {
|
||||
password_mismatch_message.text(password_not_same_as_old_password)
|
||||
.removeClass("hidden text-muted").addClass("text-danger");
|
||||
|
||||
password_strength_message.addClass("hidden");
|
||||
}
|
||||
if ((new_password.val() || old_password.val) && old_password.val() !== new_password.val()) {
|
||||
password_mismatch_message.addClass("hidden");
|
||||
password_strength_message.removeClass("hidden");
|
||||
password_mismatch_message.text('')
|
||||
}
|
||||
|
||||
if (new_password.val() === confirm_password.val() && old_password.val() !== new_password.val() ) {
|
||||
password_mismatch_message.addClass("hidden");
|
||||
password_strength_message.removeClass("hidden");
|
||||
}
|
||||
if (confirm_password.val() && new_password.val() !== confirm_password.val()) {
|
||||
password_mismatch_message.text(password_mismatch)
|
||||
.removeClass("hidden text-muted").addClass("text-danger");
|
||||
password_strength_message.addClass("hidden");
|
||||
}
|
||||
if ((key || (!key && old_password.val() )) && common_conditions ) {
|
||||
update_button.prop("disabled", false).css("cursor", "pointer");
|
||||
}
|
||||
else {
|
||||
update_button.prop("disabled", true).css("cursor", "not-allowed");
|
||||
}
|
||||
},500)
|
||||
)
|
||||
|
||||
window.test_password_strength = function() {
|
||||
window.timout_password_strength = null;
|
||||
|
||||
var args = {
|
||||
key: key || "",
|
||||
old_password: old_password.val(),
|
||||
new_password: new_password.val()
|
||||
}
|
||||
|
||||
if (!args.new_password) {
|
||||
set_strength_indicator('grey', {'warning': "{{ _('Please enter the password') }}" });
|
||||
return;
|
||||
}
|
||||
|
||||
return frappe.call({
|
||||
method: 'frappe.core.doctype.user.user.test_password_strength',
|
||||
args: args,
|
||||
callback: function(r) {
|
||||
console.log(r.message);
|
||||
},
|
||||
statusCode: {
|
||||
401: function() {
|
||||
$('.page-card-head .reset-password-heading')
|
||||
.text("{{ _('Invalid Password') }}");
|
||||
},
|
||||
200: function(r) {
|
||||
if (r.message) {
|
||||
var score = r.message.score,
|
||||
feedback = r.message.feedback;
|
||||
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.score = score;
|
||||
|
||||
if (feedback.password_policy_validation_passed) {
|
||||
set_strength_indicator('green', feedback);
|
||||
}else{
|
||||
set_strength_indicator('red', feedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
window.set_strength_indicator = function(color, feedback) {
|
||||
var message = [];
|
||||
feedback.help_msg = "";
|
||||
if(!feedback.password_policy_validation_passed){
|
||||
feedback.help_msg = "<br>" + "{{ _('Hint: Include symbols, numbers and capital letters in the password') }}";
|
||||
}
|
||||
if (feedback) {
|
||||
if(!feedback.password_policy_validation_passed){
|
||||
if (feedback.suggestions && feedback.suggestions.length) {
|
||||
message = message.concat(feedback.suggestions);
|
||||
} else if (feedback.warning) {
|
||||
message.push(feedback.warning);
|
||||
}
|
||||
message.push(feedback.help_msg);
|
||||
|
||||
} else {
|
||||
message.push(password_strength_message_success);
|
||||
}
|
||||
}
|
||||
password_mismatch_message.addClass('hidden');
|
||||
|
||||
strength_message.html(message.join(' ') || '').removeClass('hidden');
|
||||
}
|
||||
|
||||
window.clear_timeout = function() {
|
||||
if (window.timout_password_strength) {
|
||||
clearTimeout(window.timout_password_strength);
|
||||
window.timout_password_strength = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<style>
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.password-strength-indicator {
|
||||
float: right;
|
||||
padding: 15px;
|
||||
margin-top: -38px;
|
||||
margin-right: -7px;
|
||||
}
|
||||
|
||||
.password-strength-message {
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.navbar-brand,
|
||||
.navbar-toggler,
|
||||
#website-post-login,
|
||||
.logged-in,
|
||||
.btn-login-area,
|
||||
.nav-avatar,
|
||||
footer.web-footer,
|
||||
.web-footer,
|
||||
.footer-logo-extension,
|
||||
.footer-powered {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
{% include "templates/styles/card_style.css" %}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user