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