feat: update ASM UI with latest changes

This commit is contained in:
Vipeesh 2026-08-03 08:15:02 +00:00
parent f952f65515
commit abc533cf86
35 changed files with 3347 additions and 2297 deletions

View File

@ -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;

View 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;

View File

@ -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';
@ -270,13 +220,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
return location.pathname === path;
};
// ✅ Handle User Profile click
const handleUserProfileClick = () => {
navigate('/user-profile');
};
return (
<div
className={`
@ -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')}

View File

@ -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',

View 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": "لوحة التحكم",

View File

@ -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",

View File

@ -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')}

View File

@ -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);

View File

@ -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')}

View File

@ -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>

View File

@ -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

View File

@ -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')}

View File

@ -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" />

View File

@ -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')}

View File

@ -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 */}

View File

@ -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 navigate = useNavigate();
const { t } = useTranslation();
const { isRTL } = useLanguage();
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);
// Get base URL for assets
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const baseUrl = import.meta.env.BASE_URL || '/';
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765198405`; // Auto-updated by build script
: `?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,16 +138,12 @@ 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));
@ -58,7 +151,6 @@ const Login: React.FC = () => {
apiService.setSessionId(response.message.sid);
}
// Load translations from Frappe after successful login
try {
await loadFrappeTranslations();
} catch (err) {
@ -69,9 +161,10 @@ const Login: React.FC = () => {
} 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,12 +175,11 @@ 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) {
@ -97,6 +189,80 @@ const Login: React.FC = () => {
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">
@ -110,7 +276,11 @@ const Login: React.FC = () => {
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>
@ -138,6 +326,14 @@ const Login: React.FC = () => {
</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;

View File

@ -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} />

View File

@ -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" />

View File

@ -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;
let cancelled = false;
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;
});
// Create pie chart data
const labels: string[] = [];
const values: number[] = [];
if (totalUpTime > 0) {
labels.push(t('dashboard.upTime'));
values.push(totalUpTime);
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 (totalDownTime > 0) {
labels.push(t('dashboard.downTime'));
values.push(totalDownTime);
}
fetchChartAssets();
return () => { cancelled = true; };
}, []);
// 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
const upTimeLabel = t('dashboard.upTime');
const downTimeLabel = t('dashboard.downTime');
setUpDownTimeChartData({
labels: labels,
datasets: [{
name: 'Time',
values: values,
colors: pieColors
}],
type: 'Pie'
});
} else {
setUpDownTimeChartData(null);
}
} else {
setUpDownTimeChartData(null);
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>

View File

@ -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')}

View File

@ -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>

View File

@ -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>

View File

@ -139,9 +139,12 @@ 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>

View File

@ -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

View File

@ -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')}

View File

@ -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);

View File

@ -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 {

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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>

View 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 %}