Handling logout cookies and other changes
This commit is contained in:
parent
5274a58bd1
commit
f952f65515
@ -48,6 +48,7 @@ import IssueList from './pages/IssueList';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
import MaintenanceTeamList from './pages/MaintenanceTeamList';
|
||||
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
|
||||
import UserProfilePage from './pages/UserProfilePage';
|
||||
|
||||
// Layout with Sidebar and Header
|
||||
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@ -251,6 +252,12 @@ const App: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/user-profile" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@ -16,20 +16,52 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
const { language, changeLanguage } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = () => {
|
||||
// const handleLogout = () => {
|
||||
// localStorage.removeItem('user');
|
||||
// localStorage.removeItem('sid');
|
||||
// navigate('/login');
|
||||
// };
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('sid');
|
||||
navigate('/login');
|
||||
|
||||
try {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('X-Frappe-CSRF-Token='))
|
||||
?.split('=')[1] || '';
|
||||
|
||||
// Step 1: Kill server-side session in Redis
|
||||
await fetch('/api/method/frappe.auth.logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Frappe-CSRF-Token': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Step 2: Clear Frappe web session cookies fully
|
||||
await fetch('/?cmd=web_logout', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
} finally {
|
||||
window.location.href = '/asm_app/login';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{/* User Email (optional, can be shown on hover or always) */}
|
||||
{userEmail && (
|
||||
{/* {userEmail && (
|
||||
<div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
|
||||
{userEmail}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="relative">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@ -14,7 +14,9 @@ import {
|
||||
Users,
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
UserCircle
|
||||
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarLink {
|
||||
@ -32,9 +34,12 @@ interface SidebarProps {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isRTL } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [userFullName, setUserFullName] = useState<string>('');
|
||||
|
||||
// Get base URL for assets (handles both dev and production)
|
||||
// BASE_URL in Vite already includes trailing slash in production, but not in dev
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
@ -50,6 +55,51 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
? `${baseUrl}sidebar-background.jpg${imageVersion}`
|
||||
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
|
||||
|
||||
// ✅ Fetch user full name on mount
|
||||
useEffect(() => {
|
||||
const fetchUserFullName = async () => {
|
||||
try {
|
||||
// First get the logged-in user
|
||||
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
const userData = await userResponse.json();
|
||||
const userEmail = userData.message;
|
||||
|
||||
if (userEmail) {
|
||||
// Then fetch the user's full name
|
||||
const fullNameResponse = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}?fields=["full_name"]`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
const fullNameData = await fullNameResponse.json();
|
||||
|
||||
if (fullNameData.data?.full_name) {
|
||||
setUserFullName(fullNameData.data.full_name);
|
||||
} else {
|
||||
// Fallback to email if full name not found
|
||||
setUserFullName(userEmail);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user full name:', error);
|
||||
// Fallback to email prop if API fails
|
||||
if (userEmail) {
|
||||
setUserFullName(userEmail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserFullName();
|
||||
}, [userEmail]);
|
||||
|
||||
// Role-based visibility logic
|
||||
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
|
||||
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
|
||||
@ -220,6 +270,13 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
// ✅ Handle User Profile click
|
||||
const handleUserProfileClick = () => {
|
||||
navigate('/user-profile');
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
@ -328,15 +385,61 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
|
||||
{/* User Info & Version (Bottom) */}
|
||||
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}>
|
||||
{!isCollapsed && userEmail && (
|
||||
{/* {!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 && (
|
||||
|
||||
@ -60,6 +60,8 @@ const AssetDetail: React.FC = () => {
|
||||
custom_support_plan: '',
|
||||
custom_spare_parts: [],
|
||||
custom_total_spare_parts_amount: 0,
|
||||
custom_moh_supervisor:'',
|
||||
custom_team_leader: '',
|
||||
is_existing_asset: true,
|
||||
__islocal: false,
|
||||
});
|
||||
@ -430,6 +432,8 @@ const AssetDetail: React.FC = () => {
|
||||
location: asset.location || '',
|
||||
custom_manufacturer: asset.custom_manufacturer || '',
|
||||
department: asset.department || '',
|
||||
custom_moh_supervisor: asset.custom_moh_supervisor || '',
|
||||
custom_team_leader: asset.custom_team_leader || '',
|
||||
custom_asset_type: asset.custom_asset_type || '',
|
||||
custom_manufacturing_year: asset.custom_manufacturing_year || '',
|
||||
custom_model: asset.custom_model || '',
|
||||
@ -3043,6 +3047,21 @@ const handlePPMPlan = async () => {
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<LinkField
|
||||
label="MOH Supervisor"
|
||||
doctype="User"
|
||||
value={formData.custom_moh_supervisor || ''}
|
||||
onChange={(val) => setFormData({ ...formData, custom_moh_supervisor: val })}
|
||||
disabled={isFieldDisabled('custom_moh_supervisor')}
|
||||
/>
|
||||
|
||||
<LinkField
|
||||
label="Team Leader"
|
||||
doctype="User"
|
||||
value={formData.custom_team_leader || ''}
|
||||
onChange={(val) => setFormData({ ...formData, custom_team_leader: val })}
|
||||
disabled={isFieldDisabled('custom_team_leader')}
|
||||
/>
|
||||
|
||||
<LinkField
|
||||
label="Supplier/Vendor"
|
||||
|
||||
@ -329,6 +329,7 @@ const AssetList: React.FC = () => {
|
||||
{ key: 'asset_name', label: t('commonFields.assetName'), default: true },
|
||||
{ key: 'custom_serial_number', label: t('commonFields.serialNumber'), default: true },
|
||||
{ key: 'company', label: t('commonFields.company'), default: true },
|
||||
{ key: 'custom_site', label: 'Site Name', default: false },
|
||||
{ key: 'location', label: t('commonFields.location'), default: true },
|
||||
{ key: 'department', label: t('commonFields.department'), default: false },
|
||||
{ key: 'custom_device_status', label: t('commonFields.deviceStatus'), default: true },
|
||||
@ -413,6 +414,7 @@ const AssetList: React.FC = () => {
|
||||
// Filter states
|
||||
const [filterAssetId, setFilterAssetId] = useState('');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterSite, setFilterSite] = useState('');
|
||||
const [filterManufacturer, setFilterManufacturer] = useState('');
|
||||
const [filterSupplier, setFilterSupplier] = useState('');
|
||||
const [filterLocation, setFilterLocation] = useState('');
|
||||
@ -450,6 +452,7 @@ const AssetList: React.FC = () => {
|
||||
const count = [
|
||||
filterAssetId,
|
||||
filterCompany,
|
||||
filterSite,
|
||||
filterLocation,
|
||||
filterDepartment,
|
||||
filterModality,
|
||||
@ -464,6 +467,7 @@ const AssetList: React.FC = () => {
|
||||
}, [
|
||||
filterAssetId,
|
||||
filterCompany,
|
||||
filterSite,
|
||||
filterLocation,
|
||||
filterDepartment,
|
||||
filterModality,
|
||||
@ -479,6 +483,7 @@ const AssetList: React.FC = () => {
|
||||
const filters: Record<string, any> = {};
|
||||
if (filterAssetId) filters['name'] = filterAssetId;
|
||||
if (filterCompany) filters['company'] = filterCompany;
|
||||
if (filterSite) filters['custom_site'] = filterSite;
|
||||
if (filterManufacturer) filters['custom_manufacturer'] = filterManufacturer;
|
||||
if (filterSupplier) filters['supplier'] = filterSupplier;
|
||||
if (filterLocation) filters['location'] = filterLocation;
|
||||
@ -511,13 +516,13 @@ const AssetList: React.FC = () => {
|
||||
if (page !== 0) {
|
||||
setPage(0);
|
||||
}
|
||||
}, [filterAssetId, filterCompany, filterManufacturer, filterSupplier, filterLocation, filterDepartment, filterModality,
|
||||
}, [filterAssetId, filterCompany, filterSite, filterManufacturer, filterSupplier, filterLocation, filterDepartment, filterModality,
|
||||
filterDeviceStatus, filterAssetName, filterSerialNumber, searchTerm]);
|
||||
|
||||
// ✅ Clear selection when filters/page change
|
||||
useEffect(() => {
|
||||
setSelectedRows(new Set());
|
||||
}, [filterAssetId, filterCompany, filterManufacturer, filterSupplier, filterLocation, filterDepartment, filterModality,
|
||||
}, [filterAssetId, filterCompany, filterSite, filterManufacturer, filterSupplier, filterLocation, filterDepartment, filterModality,
|
||||
filterDeviceStatus, filterAssetName, filterSerialNumber, searchTerm, page]);
|
||||
|
||||
// Debounce functions
|
||||
@ -564,6 +569,7 @@ const AssetList: React.FC = () => {
|
||||
filters: {
|
||||
filterAssetId,
|
||||
filterCompany,
|
||||
filterSite,
|
||||
filterLocation,
|
||||
filterDepartment,
|
||||
filterModality,
|
||||
@ -588,6 +594,7 @@ const AssetList: React.FC = () => {
|
||||
const f = preset.filters;
|
||||
setFilterAssetId(f.filterAssetId || '');
|
||||
setFilterCompany(f.filterCompany || '');
|
||||
setFilterSite(f.filterSite || '');
|
||||
setFilterLocation(f.filterLocation || '');
|
||||
setFilterDepartment(f.filterDepartment || '');
|
||||
setFilterModality(f.filterModality || '');
|
||||
@ -868,6 +875,7 @@ const AssetList: React.FC = () => {
|
||||
const handleClearFilters = () => {
|
||||
setFilterAssetId('');
|
||||
setFilterCompany('');
|
||||
setFilterSite('');
|
||||
setFilterManufacturer('');
|
||||
setFilterSupplier('');
|
||||
setFilterLocation('');
|
||||
@ -884,7 +892,7 @@ const AssetList: React.FC = () => {
|
||||
if (serialNumberDebounceRef.current) clearTimeout(serialNumberDebounceRef.current);
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterAssetId || filterCompany || filterManufacturer || filterSupplier ||
|
||||
const hasActiveFilters = filterAssetId || filterCompany || filterSite || filterManufacturer || filterSupplier ||
|
||||
filterLocation || filterDepartment || filterModality || filterDeviceStatus ||
|
||||
filterAssetName || filterSerialNumber || searchTerm;
|
||||
|
||||
@ -1062,6 +1070,17 @@ const AssetList: React.FC = () => {
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filterSite && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-emerald-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
|
||||
<span className="font-semibold">Site:</span> {filterSite}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setFilterSite(''); }}
|
||||
className="hover:text-red-500 transition-colors"
|
||||
>
|
||||
<FaTimes className="text-[9px]" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filterAssetName && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-cyan-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
|
||||
<span className="font-semibold">Name:</span> {filterAssetName}
|
||||
@ -1324,6 +1343,27 @@ const AssetList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Name - NEW FILTER */}
|
||||
<div className="relative z-[58]">
|
||||
<LinkField
|
||||
label="PHCC Site"
|
||||
doctype="Mobile Team Site"
|
||||
value={filterSite}
|
||||
onChange={(val) => setFilterSite(val)}
|
||||
placeholder="Select Site"
|
||||
disabled={false}
|
||||
compact={true}
|
||||
/>
|
||||
{filterSite && (
|
||||
<button
|
||||
onClick={() => setFilterSite('')}
|
||||
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
|
||||
>
|
||||
<FaTimes size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device Status */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
@ -1349,7 +1389,7 @@ const AssetList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="relative z-[58]">
|
||||
<div className="relative z-[57]">
|
||||
<LinkField
|
||||
label="Location"
|
||||
doctype="Location"
|
||||
@ -1370,7 +1410,7 @@ const AssetList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Department */}
|
||||
<div className="relative z-[57]">
|
||||
<div className="relative z-[56]">
|
||||
<LinkField
|
||||
label="Department"
|
||||
doctype="Department"
|
||||
@ -1391,7 +1431,7 @@ const AssetList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Modality */}
|
||||
<div className="relative z-[56]">
|
||||
<div className="relative z-[55]">
|
||||
<LinkField
|
||||
label="Modality"
|
||||
doctype="Modality"
|
||||
@ -1412,7 +1452,7 @@ const AssetList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Manufacturer */}
|
||||
<div className="relative z-[55]">
|
||||
<div className="relative z-[54]">
|
||||
<LinkField
|
||||
label="Manufacturer"
|
||||
doctype="Manufacturer"
|
||||
@ -1433,7 +1473,7 @@ const AssetList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Supplier */}
|
||||
<div className="relative z-[54]">
|
||||
<div className="relative z-[53]">
|
||||
<LinkField
|
||||
label="Supplier"
|
||||
doctype="Supplier"
|
||||
|
||||
649
asm_app/src/pages/UserProfilePage.tsx
Normal file
649
asm_app/src/pages/UserProfilePage.tsx
Normal file
@ -0,0 +1,649 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaSave,
|
||||
FaUser,
|
||||
FaEnvelope,
|
||||
FaKey,
|
||||
FaEye,
|
||||
FaEyeSlash,
|
||||
FaSpinner,
|
||||
FaCheckCircle,
|
||||
FaTimesCircle,
|
||||
FaExclamationTriangle,
|
||||
FaUserTag,
|
||||
FaIdBadge,
|
||||
FaShieldAlt
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import LinkField from '../components/LinkField';
|
||||
import {
|
||||
fetchCurrentUserProfile,
|
||||
updateUserProfile,
|
||||
changeUserPassword,
|
||||
type UserProfile,
|
||||
type UpdateUserProfileData
|
||||
} from '../services/userProfileService';
|
||||
|
||||
const UserProfilePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// State
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isSystemManager, setIsSystemManager] = useState(false);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
middle_name: '',
|
||||
last_name: '',
|
||||
role_profile_name: '',
|
||||
custom_user_id : '',
|
||||
});
|
||||
|
||||
// Password change state
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false);
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
const [showOldPassword, setShowOldPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Fetch user profile and check role on mount
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// NEW: Check if user is System Manager
|
||||
const roleResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
roles: 'System Manager'
|
||||
})
|
||||
});
|
||||
const roleData = await roleResponse.json();
|
||||
const isSysManager = roleData.message?.has_role || false;
|
||||
setIsSystemManager(isSysManager);
|
||||
console.log('Is System Manager:', isSysManager);
|
||||
|
||||
// Fetch profile data
|
||||
const data = await fetchCurrentUserProfile();
|
||||
|
||||
// Debug: Log everything
|
||||
console.log('Full profile data:', JSON.stringify(data, null, 2));
|
||||
console.log('role_profile_name:', data.role_profile_name);
|
||||
console.log('All keys:', Object.keys(data));
|
||||
|
||||
setProfile(data);
|
||||
setFormData({
|
||||
first_name: data.first_name || '',
|
||||
middle_name: data.middle_name || '',
|
||||
last_name: data.last_name || '',
|
||||
role_profile_name: data.role_profile_name || '',
|
||||
custom_user_id: data.custom_user_id || '',
|
||||
});
|
||||
|
||||
// Debug: Log formData after setting
|
||||
console.log('FormData role_profile_name:', data.role_profile_name || '');
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load profile';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg, {
|
||||
position: "top-right",
|
||||
autoClose: 5000,
|
||||
icon: <FaTimesCircle />
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
// Handle form input changes
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle password input changes
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setPasswordData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Save profile changes
|
||||
const handleSaveProfile = async () => {
|
||||
if (!profile?.email) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const updateData: UpdateUserProfileData = {
|
||||
first_name: formData.first_name,
|
||||
middle_name: formData.middle_name,
|
||||
last_name: formData.last_name,
|
||||
role_profile_name: formData.role_profile_name,
|
||||
};
|
||||
|
||||
const updatedProfile = await updateUserProfile(profile.email, updateData);
|
||||
setProfile(prev => prev ? { ...prev, ...updatedProfile } : updatedProfile);
|
||||
|
||||
toast.success('Profile updated successfully!', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaCheckCircle />
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
toast.error(errorMsg, {
|
||||
position: "top-right",
|
||||
autoClose: 5000,
|
||||
icon: <FaTimesCircle />
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Change password
|
||||
const handleChangePassword = async () => {
|
||||
// Validation
|
||||
if (!passwordData.old_password) {
|
||||
toast.error('Please enter your current password', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaExclamationTriangle />
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordData.new_password) {
|
||||
toast.error('Please enter a new password', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaExclamationTriangle />
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordData.new_password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters long', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaExclamationTriangle />
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordData.new_password !== passwordData.confirm_password) {
|
||||
toast.error('New passwords do not match', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaExclamationTriangle />
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setChangingPassword(true);
|
||||
|
||||
await changeUserPassword(passwordData.new_password, passwordData.old_password);
|
||||
|
||||
toast.success('Password changed successfully!', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
icon: <FaCheckCircle />
|
||||
});
|
||||
|
||||
// Clear password fields
|
||||
setPasswordData({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
setShowPasswordSection(false);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to change password';
|
||||
toast.error(errorMsg, {
|
||||
position: "top-right",
|
||||
autoClose: 5000,
|
||||
icon: <FaTimesCircle />
|
||||
});
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<FaSpinner className="animate-spin text-blue-500 text-4xl mx-auto" />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !profile) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<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">
|
||||
<FaTimesCircle className="inline mr-2" />
|
||||
Error Loading Profile
|
||||
</h2>
|
||||
<p className="text-red-700 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mt-4 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={4000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="colored"
|
||||
transition={Bounce}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span className="text-gray-900 dark:text-white text-xl font-semibold">User Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={saving}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50 shadow-lg transition-all"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaSave />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Profile Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Profile Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 text-center">
|
||||
<div className="w-24 h-24 bg-white/20 rounded-full mx-auto flex items-center justify-center mb-4">
|
||||
{profile?.user_image ? (
|
||||
<img
|
||||
src={profile.user_image}
|
||||
alt="Profile"
|
||||
className="w-20 h-20 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FaUser className="text-white text-4xl" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">{profile?.full_name || 'User'}</h2>
|
||||
<p className="text-white/80 text-sm mt-1">{profile?.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Stats */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<FaIdBadge className="text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Username</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{profile?.username || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<FaUserTag className="text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Role Profile</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{profile?.role_profile_name || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<FaShieldAlt className="text-purple-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
|
||||
<p className={`text-sm font-medium ${profile?.enabled ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{profile?.enabled ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Roles */}
|
||||
{/* {profile?.roles && profile.roles.length > 0 && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Assigned Roles
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.roles.map((role, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{role.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Edit Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
<FaUser className="text-blue-500" />
|
||||
Basic Information
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Email (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
<span className="ml-1 text-gray-400">(Read-only)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FaEnvelope className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
|
||||
<input
|
||||
type="email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
className="w-full pl-10 pr-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>
|
||||
|
||||
{/* Full Name (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Full Name
|
||||
<span className="ml-1 text-gray-400">(Auto-generated)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile?.full_name || ''}
|
||||
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>
|
||||
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter first name"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Middle Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="middle_name"
|
||||
value={formData.middle_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter middle name"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter last name"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User ID No
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="custom_user_id"
|
||||
value={formData.custom_user_id}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter User No"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role Profile */}
|
||||
{/* <div>
|
||||
<LinkField
|
||||
label="Role Profile"
|
||||
doctype="Role Profile"
|
||||
value={formData.role_profile_name}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||
placeholder="Select Role Profile"
|
||||
disabled={false}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* Role Profile - Only System Manager can edit */}
|
||||
<div>
|
||||
<LinkField
|
||||
label="Role Profile"
|
||||
doctype="Role Profile"
|
||||
value={formData.role_profile_name}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||
placeholder="Select Role Profile"
|
||||
disabled={!isSystemManager}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<FaKey className="text-orange-500" />
|
||||
Change Password
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowPasswordSection(!showPasswordSection)}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{showPasswordSection ? 'Cancel' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPasswordSection ? (
|
||||
<div className="space-y-4">
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOldPassword ? 'text' : 'password'}
|
||||
name="old_password"
|
||||
value={passwordData.old_password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Enter current password"
|
||||
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showOldPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="new_password"
|
||||
value={passwordData.new_password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showNewPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordData.new_password && passwordData.new_password.length < 8 && (
|
||||
<p className="mt-1 text-xs text-red-500">Password must be at least 8 characters</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm New Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirm_password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Confirm new password"
|
||||
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordData.confirm_password && passwordData.new_password !== passwordData.confirm_password && (
|
||||
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||
)}
|
||||
{passwordData.confirm_password && passwordData.new_password === passwordData.confirm_password && passwordData.new_password.length >= 8 && (
|
||||
<p className="mt-1 text-xs text-green-500 flex items-center gap-1">
|
||||
<FaCheckCircle size={10} /> Passwords match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password Button */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={changingPassword}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white px-4 py-2.5 rounded-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{changingPassword ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin" />
|
||||
Changing Password...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaKey />
|
||||
Change Password
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
<FaKey className="mx-auto text-3xl mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm">Click "Change Password" to update your password</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfilePage;
|
||||
@ -439,7 +439,7 @@ const WorkOrderDetail: React.FC = () => {
|
||||
try {
|
||||
setIsLoadingAsset(true);
|
||||
const response = await apiService.apiCall<any>(
|
||||
`/api/resource/Asset?filters=[["custom_serial_number","=","${serialNumber}"]]&fields=["name","asset_name","company","department","custom_serial_number","custom_asset_type","custom_manufacturer","supplier","custom_site_contractor","custom_subcontractor","custom_model","custom_service_agreement","custom_service_coverage","custom_start_date","custom_end_date","custom_total_amount","custom_site"]&limit=1`
|
||||
`/api/resource/Asset?filters=[["custom_serial_number","=","${serialNumber}"]]&fields=["name","asset_name","company","department","custom_serial_number","custom_asset_type","custom_manufacturer","supplier","custom_site_contractor","custom_subcontractor","custom_model","custom_service_agreement","custom_service_coverage","custom_start_date","custom_end_date","custom_total_amount","custom_site","custom_team_leader","custom_moh_supervisor"]&limit=1`
|
||||
);
|
||||
if (response?.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
@ -483,6 +483,8 @@ const WorkOrderDetail: React.FC = () => {
|
||||
custom_start_date: formatDateForInput(assetData.custom_start_date) || '',
|
||||
custom_end_date: formatDateForInput(assetData.custom_end_date) || '',
|
||||
custom_total_amount: assetData.custom_total_amount || 0,
|
||||
custom_assigned_supervisor: assetData.custom_team_leader || '',
|
||||
custom_moh_supervisor: assetData.custom_moh_supervisor || '',
|
||||
}));
|
||||
};
|
||||
|
||||
@ -569,6 +571,9 @@ const WorkOrderDetail: React.FC = () => {
|
||||
custom_subcontractor: searchParams.get('subcontractor') || '',
|
||||
company: searchParams.get('company') || '',
|
||||
site_name: searchParams.get('site_name') || '',
|
||||
// Supervisor fields from URL params (mapped from Asset)
|
||||
custom_assigned_supervisor: searchParams.get('team_leader') || '',
|
||||
custom_moh_supervisor: searchParams.get('moh_supervisor') || '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@ -758,36 +763,35 @@ useEffect(() => {
|
||||
}, [isNewWorkOrder]);
|
||||
|
||||
// Function to call assign_supervisor_or_technician API before workflow action
|
||||
// This mirrors Frappe's before_workflow_action client script behavior
|
||||
const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => {
|
||||
if (!workOrderName || isNewWorkOrder) return null;
|
||||
// const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => {
|
||||
// if (!workOrderName || isNewWorkOrder) return null;
|
||||
|
||||
// Only call for specific actions that need assignment
|
||||
const actionsNeedingAssignment = ['Apply', 'Send For Repair'];
|
||||
if (!actionsNeedingAssignment.includes(action)) {
|
||||
return null;
|
||||
}
|
||||
// // Only call for specific actions that need assignment
|
||||
// const actionsNeedingAssignment = ['Apply', 'Send For Repair'];
|
||||
// if (!actionsNeedingAssignment.includes(action)) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
try {
|
||||
const response = await apiService.apiCall<any>(
|
||||
`/api/method/assign_supervisor_or_technician`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
work_order: workOrderName,
|
||||
action: action,
|
||||
asset_type: formData.asset_type || workOrder?.asset_type || ''
|
||||
})
|
||||
}
|
||||
);
|
||||
// try {
|
||||
// const response = await apiService.apiCall<any>(
|
||||
// `/api/method/assign_supervisor_or_technician`,
|
||||
// {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify({
|
||||
// work_order: workOrderName,
|
||||
// action: action,
|
||||
// asset_type: formData.asset_type || workOrder?.asset_type || ''
|
||||
// })
|
||||
// }
|
||||
// );
|
||||
|
||||
return response?.message || null;
|
||||
} catch (err) {
|
||||
console.error('Error in before_workflow_action:', err);
|
||||
// Don't block workflow action if this fails, just log it
|
||||
return null;
|
||||
}
|
||||
};
|
||||
// return response?.message || null;
|
||||
// } catch (err) {
|
||||
// console.error('Error in before_workflow_action:', err);
|
||||
// // Don't block workflow action if this fails, just log it
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
|
||||
// Workflow action handler
|
||||
const handleWorkflowAction = async (action: string, nextState?: string) => {
|
||||
@ -805,17 +809,7 @@ useEffect(() => {
|
||||
position: "top-right"
|
||||
});
|
||||
|
||||
// Call before_workflow_action logic (assign supervisor/technician)
|
||||
// This mirrors Frappe's before_workflow_action client script
|
||||
const assignmentResult = await callBeforeWorkflowAction(action);
|
||||
|
||||
if (assignmentResult?.assigned_to) {
|
||||
toast.info(`Assigned to: ${assignmentResult.assigned_to}`, {
|
||||
position: "top-right",
|
||||
autoClose: 4000,
|
||||
icon: <FaCheckCircle />
|
||||
});
|
||||
}
|
||||
// NOTE: Supervisor/Technician assignment is now handled when Asset is selected
|
||||
|
||||
const success = await applyAction(action, nextState);
|
||||
|
||||
|
||||
@ -339,6 +339,7 @@ const WorkOrderList: React.FC = () => {
|
||||
{ key: 'asset_name', label: t('commonFields.assetName'), default: true },
|
||||
{ key: 'work_order_type', label: t('commonFields.workOrderType'), default: true },
|
||||
{ key: 'company', label: t('commonFields.company'), default: true },
|
||||
{ key: 'site_name', label: t('commonFields.siteName') || 'Site Name', default: true },
|
||||
{ key: 'department', label: t('commonFields.department'), default: true },
|
||||
{ key: 'repair_status', label: t('commonFields.status'), default: true },
|
||||
{ key: 'custom_priority_', label: t('commonFields.priority'), default: true },
|
||||
@ -381,6 +382,7 @@ const WorkOrderList: React.FC = () => {
|
||||
const [filterAsset, setFilterAsset] = useState('');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterSiteName, setFilterSiteName] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSerialNumber, setFilterSerialNumber] = useState('');
|
||||
const [filterManufacturer, setFilterManufacturer] = useState('');
|
||||
@ -406,10 +408,12 @@ const WorkOrderList: React.FC = () => {
|
||||
const company = searchParams.get('company');
|
||||
const priority = searchParams.get('priority');
|
||||
const status = searchParams.get('status');
|
||||
const siteName = searchParams.get('site_name');
|
||||
|
||||
if (company) setFilterCompany(company);
|
||||
if (priority) setFilterPriority(priority);
|
||||
if (status) setFilterStatus(status);
|
||||
if (siteName) setFilterSiteName(siteName);
|
||||
}, [searchParams]);
|
||||
|
||||
// ✅ Load asset filter from URL on mount
|
||||
@ -434,6 +438,7 @@ const WorkOrderList: React.FC = () => {
|
||||
filterAsset,
|
||||
filterType,
|
||||
filterCompany,
|
||||
filterSiteName,
|
||||
filterStatus,
|
||||
filterSerialNumber,
|
||||
filterManufacturer,
|
||||
@ -447,6 +452,7 @@ const WorkOrderList: React.FC = () => {
|
||||
filterAsset,
|
||||
filterType,
|
||||
filterCompany,
|
||||
filterSiteName,
|
||||
filterStatus,
|
||||
filterSerialNumber,
|
||||
filterManufacturer,
|
||||
@ -461,6 +467,7 @@ const WorkOrderList: React.FC = () => {
|
||||
if (filterAsset) filters['asset'] = filterAsset;
|
||||
if (filterType) filters['work_order_type'] = filterType;
|
||||
if (filterCompany) filters['company'] = filterCompany;
|
||||
if (filterSiteName) filters['site_name'] = filterSiteName;
|
||||
if (filterStatus) filters['repair_status'] = filterStatus;
|
||||
if (filterSerialNumber) filters['custom_serial_number'] = ['like', `%${filterSerialNumber}%`];
|
||||
if (filterManufacturer) filters['custom_manufacturer'] = filterManufacturer;
|
||||
@ -472,7 +479,8 @@ const WorkOrderList: React.FC = () => {
|
||||
filters,
|
||||
limit,
|
||||
page * limit,
|
||||
'creation desc',
|
||||
// 'creation desc',
|
||||
'modified desc',
|
||||
permissionFilters
|
||||
);
|
||||
|
||||
@ -490,13 +498,13 @@ const WorkOrderList: React.FC = () => {
|
||||
if (page !== 0) {
|
||||
setPage(0);
|
||||
}
|
||||
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterStatus,
|
||||
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterSiteName, filterStatus,
|
||||
filterSerialNumber, filterManufacturer, filterSupplier, filterDepartment, filterPriority]);
|
||||
|
||||
// Clear selection when filters/page change
|
||||
useEffect(() => {
|
||||
setSelectedRows(new Set());
|
||||
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterStatus,
|
||||
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterSiteName, filterStatus,
|
||||
filterSerialNumber, filterManufacturer, filterSupplier, filterDepartment, filterPriority, page]);
|
||||
|
||||
// Debounce function for serial number
|
||||
@ -532,6 +540,7 @@ const WorkOrderList: React.FC = () => {
|
||||
filterAsset,
|
||||
filterType,
|
||||
filterCompany,
|
||||
filterSiteName,
|
||||
filterStatus,
|
||||
filterSerialNumber,
|
||||
filterManufacturer,
|
||||
@ -555,6 +564,7 @@ const WorkOrderList: React.FC = () => {
|
||||
setFilterAsset(f.filterAsset || '');
|
||||
setFilterType(f.filterType || '');
|
||||
setFilterCompany(f.filterCompany || '');
|
||||
setFilterSiteName(f.filterSiteName || '');
|
||||
setFilterStatus(f.filterStatus || '');
|
||||
setFilterSerialNumber(f.filterSerialNumber || '');
|
||||
setTempSerialNumber(f.filterSerialNumber || '');
|
||||
@ -780,6 +790,7 @@ const WorkOrderList: React.FC = () => {
|
||||
setFilterAsset('');
|
||||
setFilterType('');
|
||||
setFilterCompany('');
|
||||
setFilterSiteName('');
|
||||
setFilterStatus('');
|
||||
setFilterSerialNumber('');
|
||||
setTempSerialNumber('');
|
||||
@ -789,8 +800,9 @@ const WorkOrderList: React.FC = () => {
|
||||
setFilterPriority('');
|
||||
|
||||
// ✅ Clear URL params when clearing all filters
|
||||
if (searchParams.has('asset')) {
|
||||
if (searchParams.has('asset') || searchParams.has('site_name')) {
|
||||
searchParams.delete('asset');
|
||||
searchParams.delete('site_name');
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
|
||||
@ -798,7 +810,7 @@ const WorkOrderList: React.FC = () => {
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterWorkOrderId || filterAsset || filterType || filterCompany ||
|
||||
filterStatus || filterSerialNumber || filterManufacturer ||
|
||||
filterSiteName || filterStatus || filterSerialNumber || filterManufacturer ||
|
||||
filterSupplier || filterDepartment || filterPriority;
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
@ -1034,6 +1046,14 @@ const WorkOrderList: React.FC = () => {
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filterSiteName && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-emerald-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
|
||||
<span className="font-semibold">Site:</span> {filterSiteName}
|
||||
<button onClick={(e) => { e.stopPropagation(); setFilterSiteName(''); searchParams.delete('site_name'); setSearchParams(searchParams); }} className="hover:text-red-500 transition-colors">
|
||||
<FaTimes className="text-[9px]" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filterStatus && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
|
||||
<span className="font-semibold">Status:</span> {filterStatus}
|
||||
@ -1237,6 +1257,27 @@ const WorkOrderList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Name */}
|
||||
<div className="relative z-[56]">
|
||||
<LinkField
|
||||
label="PHCC Site"
|
||||
doctype="Mobile Team Site"
|
||||
value={filterSiteName}
|
||||
onChange={(val) => setFilterSiteName(val)}
|
||||
placeholder="Select Site"
|
||||
disabled={false}
|
||||
compact={true}
|
||||
/>
|
||||
{filterSiteName && (
|
||||
<button
|
||||
onClick={() => { setFilterSiteName(''); searchParams.delete('site_name'); setSearchParams(searchParams); }}
|
||||
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
|
||||
>
|
||||
<FaTimes size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
@ -1297,7 +1338,7 @@ const WorkOrderList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Manufacturer */}
|
||||
<div className="relative z-[56]">
|
||||
<div className="relative z-[55]">
|
||||
<LinkField
|
||||
label="Manufacturer"
|
||||
doctype="Manufacturer"
|
||||
@ -1318,7 +1359,7 @@ const WorkOrderList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Supplier */}
|
||||
<div className="relative z-[55]">
|
||||
<div className="relative z-[54]">
|
||||
<LinkField
|
||||
label="Supplier"
|
||||
doctype="Supplier"
|
||||
@ -1339,7 +1380,7 @@ const WorkOrderList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Department */}
|
||||
<div className="relative z-[54]">
|
||||
<div className="relative z-[53]">
|
||||
<LinkField
|
||||
label="Department"
|
||||
doctype="Department"
|
||||
@ -1550,12 +1591,20 @@ const WorkOrderList: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{/* <td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{workOrder.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{workOrder.creation ? new Date(workOrder.creation).toLocaleDateString() : ''}
|
||||
</div>
|
||||
</td> */}
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{workOrder.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(workOrder.modified)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{workOrder.asset_name || '-'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{workOrder.asset || ''}</div>
|
||||
|
||||
@ -3,6 +3,8 @@ import API_CONFIG from '../config/api';
|
||||
|
||||
// Asset Interfaces
|
||||
export interface Asset {
|
||||
custom_team_leader: string;
|
||||
custom_moh_supervisor: string;
|
||||
name: string;
|
||||
asset_name: string;
|
||||
company: string;
|
||||
|
||||
190
asm_app/src/services/userProfileService.ts
Normal file
190
asm_app/src/services/userProfileService.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import apiService from './apiService';
|
||||
|
||||
export interface UserProfile {
|
||||
custom_user_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
middle_name?: string;
|
||||
last_name?: string;
|
||||
full_name: string;
|
||||
role_profile_name?: string;
|
||||
username?: string;
|
||||
language?: string;
|
||||
time_zone?: string;
|
||||
user_image?: string;
|
||||
enabled: number;
|
||||
roles: Array<{
|
||||
role: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UpdateUserProfileData {
|
||||
first_name?: string;
|
||||
middle_name?: string;
|
||||
last_name?: string;
|
||||
role_profile_name?: string;
|
||||
new_password?: string;
|
||||
custom_user_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current logged-in user's profile
|
||||
*/
|
||||
export const fetchCurrentUserProfile = async (): Promise<UserProfile> => {
|
||||
try {
|
||||
// First get the current user's email
|
||||
const sessionResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const sessionData = await sessionResponse.json();
|
||||
const userEmail = sessionData.message;
|
||||
|
||||
if (!userEmail) {
|
||||
throw new Error('No user logged in');
|
||||
}
|
||||
|
||||
// Use frappe.client.get which returns all fields including role_profile_name
|
||||
const response = await fetch('/api/method/frappe.client.get', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
doctype: 'User',
|
||||
name: userEmail,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Debug: Log the response
|
||||
console.log('frappe.client.get response:', result);
|
||||
console.log('role_profile_name:', result.message?.role_profile_name);
|
||||
|
||||
if (result.message) {
|
||||
const userData = result.message as UserProfile;
|
||||
|
||||
// Roles are included in the response as a child table
|
||||
if (!userData.roles) {
|
||||
userData.roles = [];
|
||||
}
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch user profile');
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export const updateUserProfile = async (
|
||||
userEmail: string,
|
||||
data: UpdateUserProfileData
|
||||
): Promise<UserProfile> => {
|
||||
try {
|
||||
const response = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.exc || result.message || 'Failed to update profile');
|
||||
}
|
||||
|
||||
return result.data as UserProfile;
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Change user password using Frappe's update_password method
|
||||
*/
|
||||
export const changeUserPassword = async (
|
||||
newPassword: string,
|
||||
oldPassword?: string
|
||||
): Promise<{ message: string }> => {
|
||||
try {
|
||||
const response = await fetch('/api/method/frappe.core.doctype.user.user.update_password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
new_password: newPassword,
|
||||
old_password: oldPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Parse error message
|
||||
let errorMsg = 'Failed to change password';
|
||||
if (result.exc) {
|
||||
// Try to extract meaningful error from exception
|
||||
if (result.exc.includes('Incorrect Old Password')) {
|
||||
errorMsg = 'Incorrect old password';
|
||||
} else if (result.exc.includes('Password cannot be same')) {
|
||||
errorMsg = 'New password cannot be the same as old password';
|
||||
} else if (result._server_messages) {
|
||||
try {
|
||||
const messages = JSON.parse(result._server_messages);
|
||||
errorMsg = JSON.parse(messages[0]).message || errorMsg;
|
||||
} catch (e) {
|
||||
// Use default error
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return { message: 'Password changed successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch role profiles for dropdown
|
||||
*/
|
||||
export const fetchRoleProfiles = async (): Promise<Array<{ name: string }>> => {
|
||||
try {
|
||||
const response = await apiService.apiCall<any>(
|
||||
`/api/resource/Role Profile?fields=["name"]&limit_page_length=100`
|
||||
);
|
||||
|
||||
return response?.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching role profiles:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
fetchCurrentUserProfile,
|
||||
updateUserProfile,
|
||||
changeUserPassword,
|
||||
fetchRoleProfiles,
|
||||
};
|
||||
1735
asm_ui_app/public/asm_app/assets/index-BIWpBVtY.js
Normal file
1735
asm_ui_app/public/asm_app/assets/index-BIWpBVtY.js
Normal file
File diff suppressed because one or more lines are too long
1
asm_ui_app/public/asm_app/assets/index-BOZnpaxf.css
Normal file
1
asm_ui_app/public/asm_app/assets/index-BOZnpaxf.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Seera Arabia Asset Management System" />
|
||||
<title>Seera Arabia - Asset Management System</title>
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DJlPs5uL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-vHgZyeJv.css">
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-BIWpBVtY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BOZnpaxf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Seera Arabia Asset Management System" />
|
||||
<title>Seera Arabia - Asset Management System</title>
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DJlPs5uL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-vHgZyeJv.css">
|
||||
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-BIWpBVtY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BOZnpaxf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user