first commit.
This commit is contained in:
17
src/App.tsx
17
src/App.tsx
@@ -33,6 +33,9 @@ import {
|
|||||||
|
|
||||||
import { isTokenExpired, logout } from './utils/auth';
|
import { isTokenExpired, logout } from './utils/auth';
|
||||||
|
|
||||||
|
// Normalise any role string to lowercase_underscore for comparison
|
||||||
|
const normaliseRole = (r: string) => r.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
// --- ROLE-BASED ACCESS CONTROL ---
|
// --- ROLE-BASED ACCESS CONTROL ---
|
||||||
const RoleProtectedRoute: React.FC<{
|
const RoleProtectedRoute: React.FC<{
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
@@ -44,11 +47,13 @@ const RoleProtectedRoute: React.FC<{
|
|||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
const userRoles = Array.isArray(user?.roles) ? user.roles : [];
|
const userRoles = Array.isArray(user?.roles) ? user.roles : [];
|
||||||
const hasAccess = allowedRoles.some(role => userRoles.includes(role)) || userRoles.includes('CURESELECT_ADMIN');
|
const hasAccess = allowedRoles.some(allowed =>
|
||||||
|
userRoles.some((r: string) => normaliseRole(r) === normaliseRole(allowed))
|
||||||
|
) || userRoles.some((r: string) => normaliseRole(r) === 'cureselect_admin');
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
// Redirect to their respective "home" if they don't have access
|
if (userRoles.some((r: string) => normaliseRole(r) === 'fleet_operator'))
|
||||||
if (userRoles.includes('FLEET_OPERATOR')) return <Navigate to="/fleet-operator" replace />;
|
return <Navigate to="/fleet-operator" replace />;
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +116,7 @@ function AppContent() {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const isLoginPage = location.pathname.startsWith('/login') || location.pathname === '/fleet-login' || location.pathname === '/launcher';
|
const isLoginPage = location.pathname.startsWith('/login') || location.pathname === '/fleet-login' || location.pathname === '/launcher';
|
||||||
|
const isFleetPage = location.pathname.startsWith('/fleet-operator');
|
||||||
|
|
||||||
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
||||||
const user = JSON.parse(localStorage.getItem('teleems_user') || '{}');
|
const user = JSON.parse(localStorage.getItem('teleems_user') || '{}');
|
||||||
@@ -141,13 +147,14 @@ function AppContent() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="scanline" />
|
<div className="scanline" />
|
||||||
<TopBar />
|
{!isFleetPage && <TopBar />}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
isAuthenticated ? (
|
isAuthenticated ? (
|
||||||
user?.roles?.includes('FLEET_OPERATOR') && !user?.roles?.includes('CURESELECT_ADMIN')
|
user?.roles?.some((r: string) => normaliseRole(r) === 'fleet_operator') &&
|
||||||
|
!user?.roles?.some((r: string) => normaliseRole(r) === 'cureselect_admin')
|
||||||
? <Navigate to="/fleet-operator" replace />
|
? <Navigate to="/fleet-operator" replace />
|
||||||
: <Dashboard />
|
: <Dashboard />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export const fleetApi = {
|
|||||||
return apiClient.post('/v1/fleet/stations', stationData, { token });
|
return apiClient.post('/v1/fleet/stations', stationData, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateStation: async (stationId: string, stationData: any, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/fleet/stations/${stationId}`, stationData, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getStationDetails: async (stationId: string, token: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/stations/${stationId}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
getStations: async (token: string, organisationId?: string) => {
|
getStations: async (token: string, organisationId?: string) => {
|
||||||
const url = organisationId
|
const url = organisationId
|
||||||
? `/v1/fleet/stations?organisationId=${organisationId}`
|
? `/v1/fleet/stations?organisationId=${organisationId}`
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const currentPerspective = perspectives.find(p => p.id === currentRole) || perspectives[0];
|
const currentPerspective = perspectives.find(p => p.id.toLowerCase() === currentRole.toLowerCase()) || perspectives[0];
|
||||||
const Icon = currentPerspective.icon;
|
const Icon = currentPerspective.icon;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,7 +158,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol
|
|||||||
</div>
|
</div>
|
||||||
{items.map((p) => {
|
{items.map((p) => {
|
||||||
const ItemIcon = p.icon;
|
const ItemIcon = p.icon;
|
||||||
const isActive = p.id === currentRole;
|
const isActive = p.id.toLowerCase() === currentRole.toLowerCase();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Zap,
|
Zap,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
MoreVertical
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
||||||
@@ -16,6 +17,7 @@ import { logout } from '../utils/auth';
|
|||||||
export const Sidebar: React.FC = () => {
|
export const Sidebar: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
// Safely parse user data
|
// Safely parse user data
|
||||||
const user = useMemo(() => {
|
const user = useMemo(() => {
|
||||||
@@ -24,8 +26,9 @@ export const Sidebar: React.FC = () => {
|
|||||||
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
// Normalise roles to lowercase_underscore so "Fleet Operator" === "FLEET_OPERATOR"
|
||||||
parsed.roles = Array.isArray(parsed.roles)
|
parsed.roles = Array.isArray(parsed.roles)
|
||||||
? parsed.roles.map((r: any) => String(r).toUpperCase())
|
? parsed.roles.map((r: any) => String(r).toLowerCase().replace(/\s+/g, '_'))
|
||||||
: [];
|
: [];
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -41,25 +44,54 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleSwitch = (role: string) => {
|
const handleRoleSwitch = (role: string) => {
|
||||||
const updatedUser = { ...user, roles: [role.toUpperCase()] };
|
// Preserve CURESELECT_ADMIN so the admin doesn't lose access to the perspective switcher when switching roles
|
||||||
|
const originalStored = localStorage.getItem('teleems_user');
|
||||||
|
let originalRoles: string[] = [];
|
||||||
|
try {
|
||||||
|
if (originalStored) {
|
||||||
|
const parsed = JSON.parse(originalStored);
|
||||||
|
originalRoles = Array.isArray(parsed?.roles) ? parsed.roles : [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdmin = originalRoles.some((r: string) => {
|
||||||
|
const normalized = r.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
return normalized === 'cureselect_admin' || normalized === 'admin';
|
||||||
|
});
|
||||||
|
|
||||||
|
let newRoles = [role.toUpperCase()];
|
||||||
|
if (hasAdmin && role.toUpperCase() !== 'CURESELECT_ADMIN') {
|
||||||
|
newRoles.push('CURESELECT_ADMIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = { ...user, roles: newRoles };
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentRole = user.roles?.[0] || 'CURESELECT_ADMIN';
|
const currentRole = user.roles?.[0] || 'cureselect_admin';
|
||||||
const displayName = String(user.username || (currentRole === 'CURESELECT_ADMIN' ? 'CureSelect Admin' : 'Admin User'));
|
const displayName = String(user.name || user.username || (currentRole === 'cureselect_admin' ? 'CureSelect Admin' : 'Fleet Operator'));
|
||||||
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
||||||
const initials = currentRole === 'CURESELECT_ADMIN' ? 'CA' : (displayName.substring(0, 2).toUpperCase() || 'AU');
|
const initials = (displayName.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)) || 'FO';
|
||||||
|
|
||||||
const filteredNavItems = useMemo(() => {
|
const filteredNavItems = useMemo(() => {
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : [];
|
// The active perspective role being viewed (e.g. 'hospital_admin', 'fleet_operator', 'cureselect_admin')
|
||||||
const adminRoles = ['CURESELECT_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'SUPERADMIN'];
|
const activeRole = currentRole.toLowerCase().replace(/\s+/g, '_');
|
||||||
const hasAdminRole = userRoles.some(r => adminRoles.includes(r));
|
|
||||||
|
// Check if the active perspective is a platform-wide admin role
|
||||||
|
const isAdminPerspective = ['cureselect_admin', 'admin', 'super_admin', 'superadmin'].includes(activeRole);
|
||||||
|
|
||||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||||
return items.filter(item => {
|
return items.filter(item => {
|
||||||
const hasItemRole = item.roles.some(role => userRoles.includes(role.toUpperCase()));
|
// If viewing as CureSelect Admin, they can see everything (or we filter as admin)
|
||||||
return hasAdminRole || hasItemRole;
|
if (isAdminPerspective) return true;
|
||||||
|
|
||||||
|
// Otherwise, filter items so they must match the active role perspective
|
||||||
|
return item.roles.some(role =>
|
||||||
|
role.toLowerCase().replace(/\s+/g, '_') === activeRole
|
||||||
|
);
|
||||||
}).map(item => ({
|
}).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
||||||
@@ -67,59 +99,111 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return filterItems(NAVIGATION_CONFIG);
|
return filterItems(NAVIGATION_CONFIG);
|
||||||
}, [user.roles]);
|
}, [currentRole]);
|
||||||
|
|
||||||
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
||||||
const Icon = item.icon || AlertCircle;
|
const Icon = item.icon || AlertCircle;
|
||||||
const isActive = location.pathname === item.path || (item.path.includes('?') && location.pathname + location.search === item.path);
|
const currentUrl = location.pathname + location.search;
|
||||||
|
|
||||||
|
// Split path into pathname + search to handle query params correctly
|
||||||
|
const [itemPathname, itemSearch] = item.path.split('?');
|
||||||
|
const itemTo = itemSearch
|
||||||
|
? { pathname: itemPathname, search: `?${itemSearch}` }
|
||||||
|
: item.path;
|
||||||
|
|
||||||
|
const isActive = currentUrl === item.path ||
|
||||||
|
(itemSearch ? location.pathname === itemPathname && location.search === `?${itemSearch}` : location.pathname === item.path);
|
||||||
const hasSubItems = item.subItems && item.subItems.length > 0;
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
||||||
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => location.pathname === sub.path.split('?')[0]));
|
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => {
|
||||||
|
const [subPath, subSearch] = sub.path.split('?');
|
||||||
|
return subSearch
|
||||||
|
? location.pathname === subPath && location.search === `?${subSearch}`
|
||||||
|
: location.pathname === subPath;
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path}
|
to={itemTo}
|
||||||
style={({ isActive: linkActive }) => ({
|
title={isSidebarCollapsed ? item.label : undefined}
|
||||||
display: 'flex',
|
style={({ isActive: linkActive }) => {
|
||||||
alignItems: 'center',
|
const active = linkActive || isActive;
|
||||||
gap: '12px',
|
return {
|
||||||
padding: isSubItem ? '8px 20px 8px 48px' : '10px 20px',
|
display: 'flex',
|
||||||
textDecoration: 'none',
|
alignItems: 'center',
|
||||||
color: (linkActive || isActive) ? 'var(--accent-cyan)' : 'var(--text-secondary)',
|
justifyContent: isSidebarCollapsed ? 'center' : 'flex-start',
|
||||||
borderLeft: !isSubItem && (linkActive || isActive) ? '3px solid var(--accent-cyan)' : '3px solid transparent',
|
gap: isSidebarCollapsed ? '0' : '12px',
|
||||||
background: (linkActive || isActive) ? 'rgba(59, 130, 246, 0.08)' : 'transparent',
|
padding: isSidebarCollapsed ? '12px' : isSubItem ? '10px 20px 10px 48px' : '12px 16px',
|
||||||
transition: 'all 0.25s ease',
|
margin: isSidebarCollapsed ? '4px 12px' : '2px 12px',
|
||||||
textShadow: (linkActive || isActive) ? '0 0 10px rgba(59, 130, 246, 0.4)' : 'none',
|
borderRadius: '12px',
|
||||||
minWidth: 0,
|
textDecoration: 'none',
|
||||||
position: 'relative'
|
color: active ? '#fff' : '#94A3B8',
|
||||||
})}
|
background: active ? 'linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(59, 130, 246, 0.05))' : 'transparent',
|
||||||
|
borderLeft: active && !isSubItem && !isSidebarCollapsed ? '3px solid #06B6D4' : '3px solid transparent',
|
||||||
|
boxShadow: active && isSidebarCollapsed ? '0 0 0 1px rgba(6,182,212,0.4)' : 'none',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
className={(navData) => `nav-item-link ${navData.isActive || isActive ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{!isSubItem && <Icon size={18} style={{ flexShrink: 0 }} />}
|
{({ isActive: linkActive }) => {
|
||||||
<span style={{
|
const active = linkActive || isActive;
|
||||||
fontWeight: (isActive || isParentActive) ? 700 : 500,
|
return (
|
||||||
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
<>
|
||||||
whiteSpace: 'nowrap',
|
<Icon size={20} color={active ? '#06B6D4' : 'currentColor'} style={{ flexShrink: 0 }} />
|
||||||
overflow: 'hidden',
|
<AnimatePresence>
|
||||||
textOverflow: 'ellipsis',
|
{!isSidebarCollapsed && (
|
||||||
flex: 1
|
<motion.div
|
||||||
}}>
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
{item.label}
|
style={{ flex: 1, display: 'flex', alignItems: 'center', overflow: 'hidden' }}
|
||||||
</span>
|
>
|
||||||
{hasSubItems && (
|
<span style={{
|
||||||
isParentActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
fontWeight: (active || isParentActive) ? 700 : 500,
|
||||||
)}
|
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{hasSubItems && (
|
||||||
|
<div style={{
|
||||||
|
transform: isParentActive ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
color: '#94A3B8'
|
||||||
|
}}>
|
||||||
|
<ChevronRight size={16} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasSubItems && isParentActive && (
|
{hasSubItems && isParentActive && !isSidebarCollapsed && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
style={{ overflow: 'hidden', background: 'rgba(255,255,255,0.02)' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
<div style={{
|
||||||
|
borderLeft: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
marginLeft: '28px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -128,106 +212,153 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="glass" style={{
|
<>
|
||||||
width: 'var(--sidebar-width)',
|
<style>{`
|
||||||
minWidth: 'var(--sidebar-width)',
|
.nav-item-link:not(.active):hover {
|
||||||
flexBasis: 'var(--sidebar-width)',
|
background: rgba(255,255,255,0.03) !important;
|
||||||
flexShrink: 0,
|
color: #fff !important;
|
||||||
height: '100vh',
|
transform: translateX(6px);
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
`}</style>
|
||||||
borderRight: '1px solid var(--card-border)',
|
<motion.aside
|
||||||
background: 'var(--glass-bg)',
|
initial={{ width: 280 }}
|
||||||
zIndex: 1100,
|
animate={{ width: isSidebarCollapsed ? 80 : 280 }}
|
||||||
position: 'relative'
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
}}>
|
style={{
|
||||||
<div style={{
|
background: '#040B16', // Deep dark aesthetic
|
||||||
padding: '20px 20px',
|
borderRight: '1px solid rgba(255,255,255,0.05)',
|
||||||
borderBottom: '1px solid var(--card-border)',
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
height: '100vh',
|
||||||
gap: '10px',
|
zIndex: 1100,
|
||||||
flexShrink: 0,
|
position: 'relative',
|
||||||
}}>
|
fontFamily: "'Inter', sans-serif"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '30px',
|
padding: isSidebarCollapsed ? '24px 16px' : '24px',
|
||||||
height: '30px',
|
|
||||||
background: 'var(--accent-cyan)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: isSidebarCollapsed ? 'center' : 'space-between',
|
||||||
boxShadow: '0 0 12px var(--accent-cyan)',
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<Zap size={18} color="#000" />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
</div>
|
<div
|
||||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent-cyan)', margin: 0, letterSpacing: '-0.3px' }}>CureSelect</h2>
|
onClick={() => { if (isSidebarCollapsed) setIsSidebarCollapsed(false); }}
|
||||||
</div>
|
title={isSidebarCollapsed ? "Expand Menu" : undefined}
|
||||||
|
style={{
|
||||||
<nav style={{ flex: 1, padding: '12px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
width: '36px',
|
||||||
{filteredNavItems.map(item => renderNavItem(item))}
|
height: '36px',
|
||||||
</nav>
|
background: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
|
||||||
|
borderRadius: '10px',
|
||||||
<div style={{
|
display: 'flex',
|
||||||
padding: '12px 16px',
|
alignItems: 'center',
|
||||||
borderTop: '1px solid var(--card-border)',
|
justifyContent: 'center',
|
||||||
display: 'flex',
|
boxShadow: '0 0 20px rgba(6,182,212,0.4)',
|
||||||
flexDirection: 'column',
|
flexShrink: 0,
|
||||||
gap: '10px',
|
cursor: isSidebarCollapsed ? 'pointer' : 'default'
|
||||||
flexShrink: 0,
|
}}
|
||||||
}}>
|
>
|
||||||
{/* Perspective Switcher */}
|
<Zap size={20} color="#FFFFFF" strokeWidth={2.5} />
|
||||||
{(user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && (
|
|
||||||
<PerspectiveSwitcher
|
|
||||||
currentRole={currentRole}
|
|
||||||
onSwitch={handleRoleSwitch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User row */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, overflow: 'hidden' }}>
|
|
||||||
<div style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'linear-gradient(135deg, var(--accent-cyan), var(--accent-green))',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#000',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>{initials}</div>
|
|
||||||
<div style={{ minWidth: 0, overflow: 'hidden' }}>
|
|
||||||
<div style={{ fontSize: '0.78rem', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
|
||||||
<div style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>ID: {displayId}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0, width: 0 }} animate={{ opacity: 1, width: 'auto' }} exit={{ opacity: 0, width: 0 }} style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 900, color: '#fff', margin: 0, letterSpacing: '-0.5px', lineHeight: 1.2 }}>CureSelect</h2>
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#06B6D4', textTransform: 'uppercase', letterSpacing: '2px' }}>Platform</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
{/* Minimize/Collapse button near CureSelect title */}
|
||||||
className="hover-glow"
|
{!isSidebarCollapsed && (
|
||||||
style={{
|
<button
|
||||||
background: 'rgba(239, 68, 68, 0.1)',
|
onClick={() => setIsSidebarCollapsed(true)}
|
||||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
title="Collapse Menu"
|
||||||
borderRadius: '8px',
|
style={{
|
||||||
padding: '7px',
|
background: 'rgba(255,255,255,0.02)',
|
||||||
color: 'var(--alert-red)',
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
cursor: 'pointer',
|
borderRadius: '8px',
|
||||||
display: 'flex',
|
padding: '6px',
|
||||||
alignItems: 'center',
|
color: '#94A3B8',
|
||||||
justifyContent: 'center',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
display: 'flex',
|
||||||
flexShrink: 0,
|
alignItems: 'center',
|
||||||
}}
|
justifyContent: 'center',
|
||||||
title="Sign Out"
|
transition: 'all 0.2s'
|
||||||
>
|
}}
|
||||||
<LogOut size={15} />
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
|
||||||
</button>
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.02)'}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</aside>
|
{/* Navigation Area */}
|
||||||
|
<nav style={{ flex: 1, padding: '16px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} style={{ padding: '0 24px', marginBottom: '8px', fontSize: '0.65rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||||
|
Main Menu
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{filteredNavItems.map(item => renderNavItem(item))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Footer Profile */}
|
||||||
|
<div style={{ padding: '16px', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column', gap: '12px', flexShrink: 0 }}>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}>
|
||||||
|
{/* Perspective Switcher */}
|
||||||
|
{(user.roles?.includes('cureselect_admin') || user.roles?.includes('admin') || user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<PerspectiveSwitcher
|
||||||
|
currentRole={currentRole}
|
||||||
|
onSwitch={handleRoleSwitch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Premium User Card */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
|
||||||
|
padding: '12px', background: 'rgba(6, 182, 212, 0.05)', border: '1px solid rgba(6, 182, 212, 0.1)',
|
||||||
|
borderRadius: '12px', transition: 'all 0.3s ease', cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px', height: '32px', borderRadius: '10px', background: '#040B16',
|
||||||
|
border: '1px solid #06B6D4', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem', fontWeight: 800, color: '#06B6D4', flexShrink: 0
|
||||||
|
}}>{initials}</div>
|
||||||
|
<div style={{ minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 600, color: '#06B6D4', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{currentRole.replace(/_/g, ' ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleLogout(); }}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '4px', borderRadius: '6px', transition: 'all 0.2s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
title="Sign Out"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,21 +56,16 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
path: '/caller',
|
path: '/caller',
|
||||||
roles: ['CURESELECT_ADMIN', 'CALLER']
|
roles: ['CURESELECT_ADMIN', 'CALLER']
|
||||||
},
|
},
|
||||||
{
|
{ id: 'fleet-overview', label: 'Live Dashboard', icon: LayoutGrid, path: '/fleet-operator?tab=overview', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
id: 'fleet-operator',
|
{ id: 'fleet-organization', label: 'Org & Stations', icon: Hospital, path: '/fleet-operator?tab=organization', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
label: 'Fleet Command',
|
{ id: 'fleet-assets', label: 'Fleet Assets', icon: Truck, path: '/fleet-operator?tab=assets', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
icon: Zap,
|
{ id: 'fleet-personnel', label: 'Staf Management', icon: Users, path: '/fleet-operator?tab=personnel', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
path: '/fleet-operator',
|
{ id: 'fleet-mission', label: 'Crew Scheduling', icon: Navigation, path: '/fleet-operator?tab=scheduling', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'],
|
{ id: 'fleet-inventory', label: 'Inventory Management', icon: ShoppingCart, path: '/fleet-operator?tab=inventory', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
subItems: [
|
{ id: 'fleet-trips', label: 'Trip Management', icon: Activity, path: '/fleet-operator?tab=trips', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-overview', label: 'Command Center', icon: LayoutGrid, path: '/fleet-operator?tab=overview', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-telematics', label: 'GPS Telematics', icon: Navigation, path: '/fleet-operator?tab=telematics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-assets', label: 'Fleet Assets', icon: Truck, path: '/fleet-operator?tab=assets', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-referrals', label: 'Referral Network', icon: HeartPulse, path: '/fleet-operator?tab=referrals', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-personnel', label: 'Personnel Hub', icon: Users, path: '/fleet-operator?tab=personnel', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-intel', label: 'Fleet Analytics', icon: PieChart, path: '/fleet-operator?tab=analytics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-mission', label: 'Mission Control', icon: Navigation, path: '/fleet-operator?tab=scheduling', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
|
||||||
{ id: 'fleet-inventory', label: 'Supply Chain', icon: ShoppingCart, path: '/fleet-operator?tab=inventory', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
|
||||||
{ id: 'fleet-intel', label: 'Fleet Intel', icon: Activity, path: '/fleet-operator?tab=analytics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'clinical',
|
id: 'clinical',
|
||||||
label: 'Clinical Intelligence',
|
label: 'Clinical Intelligence',
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<App />,
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import { useNavigate, NavLink } from 'react-router-dom';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Truck,
|
Truck,
|
||||||
Zap,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Lock,
|
Lock,
|
||||||
User,
|
User,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Cpu,
|
Cpu,
|
||||||
Radio,
|
|
||||||
Activity,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Crosshair,
|
MapPin,
|
||||||
Signal
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import './Login.css'; // Reuse core login styles but we'll override some for the tactical look
|
import './Login.css';
|
||||||
|
|
||||||
export const FleetLogin = () => {
|
export const FleetLogin = () => {
|
||||||
const [username, setUsername] = useState('fleet_operator');
|
const [username, setUsername] = useState('fleet_operator');
|
||||||
@@ -28,7 +25,7 @@ export const FleetLogin = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showError, setShowError] = useState('');
|
const [showError, setShowError] = useState('');
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
||||||
const [tempUser, setTempUser] = useState<any>(null);
|
const [tempUser, setTempUser] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
@@ -39,43 +36,75 @@ export const FleetLogin = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setShowError('');
|
setShowError('');
|
||||||
|
|
||||||
// --- MOCK LOGIN FOR FLEET OPERATOR ---
|
// Clear any leftover mock tokens to ensure a real network call
|
||||||
if (username === 'fleet_operator' && password === 'Fleet@123') {
|
console.log('--- STARTING FLEET LOGIN ---');
|
||||||
setTimeout(() => {
|
console.log('Clearing local storage tokens...');
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.removeItem('teleems_token');
|
||||||
localStorage.setItem('teleems_token', 'mock-fleet-token-2026');
|
localStorage.removeItem('teleems_auth');
|
||||||
localStorage.setItem('teleems_user', JSON.stringify({
|
localStorage.removeItem('teleems_user');
|
||||||
id: 'fleet-op-001',
|
|
||||||
username: 'fleet_operator',
|
|
||||||
roles: ['FLEET_OPERATOR'],
|
|
||||||
metadata: {
|
|
||||||
organization: { company_name: 'TeleEMS Fleet Services' }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setIsLoading(false);
|
|
||||||
navigate('/fleet-operator');
|
|
||||||
}, 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login(username, password);
|
console.log('[FleetLogin] Logging in as:', username);
|
||||||
|
|
||||||
if (response.status === 201 || response.status === 200) {
|
// Clear old session first
|
||||||
if (response.data.mfa_required) {
|
localStorage.removeItem('teleems_token');
|
||||||
setMfaSessionToken(response.data.mfa_session_token || '');
|
localStorage.removeItem('teleems_user');
|
||||||
setTempUser(response.data.user || null);
|
localStorage.removeItem('teleems_auth');
|
||||||
|
|
||||||
|
// Step 1: Login — use raw fetch, NOT apiClient (bypass mock/401 interceptors)
|
||||||
|
const loginRes = await fetch('https://teleems-api-gateway.onrender.com/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const loginJson = await loginRes.json();
|
||||||
|
console.log('[FleetLogin] Login response status:', loginRes.status, loginJson);
|
||||||
|
|
||||||
|
if (loginRes.status === 201 || loginRes.status === 200) {
|
||||||
|
if (loginJson.data?.mfa_required) {
|
||||||
|
setMfaSessionToken(loginJson.data.mfa_session_token || '');
|
||||||
|
setTempUser(loginJson.data.user || null);
|
||||||
setLoginStep('mfa');
|
setLoginStep('mfa');
|
||||||
} else {
|
} else {
|
||||||
|
const accessToken = loginJson.data?.access_token || '';
|
||||||
|
if (!accessToken) {
|
||||||
|
setShowError('Login failed: No access token received.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token immediately
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
localStorage.setItem('teleems_token', accessToken);
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(response.data.user || {}));
|
console.log('[FleetLogin] Token stored. Fetching /auth/me...');
|
||||||
navigate('/fleet-operator');
|
|
||||||
|
// Step 2: Fetch real profile from /auth/me
|
||||||
|
try {
|
||||||
|
const meRes = await fetch('https://teleems-api-gateway.onrender.com/v1/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const meJson = await meRes.json();
|
||||||
|
console.log('[FleetLogin] /auth/me status:', meRes.status, meJson);
|
||||||
|
|
||||||
|
const profile = meJson?.data || loginJson.data?.user || {};
|
||||||
|
const roles: string[] = Array.isArray(profile.roles) ? [...profile.roles] : ['Fleet Operator'];
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify({ ...profile, roles }));
|
||||||
|
} catch (meErr) {
|
||||||
|
console.warn('[FleetLogin] /auth/me failed, using login user data:', meErr);
|
||||||
|
const fallback = loginJson.data?.user || {};
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify({ ...fallback, roles: ['Fleet Operator'] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/fleet-operator?tab=organization');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setShowError(response.message || 'Access Denied: Invalid Credentials');
|
setShowError(loginJson?.message || 'Access Denied: Invalid Credentials');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
console.error('[FleetLogin] Error:', err);
|
||||||
setShowError('Tactical Network Unavailable: Check Connection');
|
setShowError('Tactical Network Unavailable: Check Connection');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -93,14 +122,17 @@ export const FleetLogin = () => {
|
|||||||
if (response.status === 201 || response.status === 200) {
|
if (response.status === 201 || response.status === 200) {
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||||
const userToStore = response.data.user || tempUser || {};
|
const baseUser: Record<string, unknown> = (response.data.user || tempUser || {}) as Record<string, unknown>;
|
||||||
userToStore.mfa_enabled = true;
|
const roles = Array.isArray(baseUser.roles) ? [...baseUser.roles] : [];
|
||||||
|
if (!roles.includes('FLEET_OPERATOR')) roles.push('FLEET_OPERATOR');
|
||||||
|
|
||||||
|
const userToStore = { ...baseUser, roles, mfa_enabled: true };
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||||
navigate('/fleet-operator');
|
navigate('/fleet-operator?tab=organization');
|
||||||
} else {
|
} else {
|
||||||
setShowError('Invalid Security Token');
|
setShowError('Invalid Security Token');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
setShowError('Token Verification Failed');
|
setShowError('Token Verification Failed');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -108,189 +140,307 @@ export const FleetLogin = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page fleet-login-theme" style={{ background: '#020617' }}>
|
<div className="fleet-login-container" style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#020617', fontFamily: "'Outfit', sans-serif", overflow: 'hidden' }}>
|
||||||
{/* Tactical Background Elements */}
|
|
||||||
<div className="login-grid-decor" style={{ opacity: 0.1, backgroundImage: 'linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
|
||||||
<div className="scanline" style={{ background: 'linear-gradient(to bottom, transparent 0%, rgba(59, 130, 246, 0.05) 50%, transparent 100%)' }} />
|
|
||||||
<div className="login-overlay" style={{ background: 'radial-gradient(circle at center, transparent 0%, rgba(2, 6, 23, 0.8) 100%)' }} />
|
|
||||||
|
|
||||||
{/* Decorative Radar/Circle */}
|
{/* LEFT SIDE - IMAGE & TACTICAL HUD */}
|
||||||
<motion.div
|
<div className="fleet-login-left" style={{
|
||||||
animate={{ rotate: 360 }}
|
flex: 1,
|
||||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
position: 'relative',
|
||||||
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '600px', height: '600px', border: '1px solid rgba(59, 130, 246, 0.05)', borderRadius: '50%', pointerEvents: 'none' }}
|
display: 'flex',
|
||||||
>
|
flexDirection: 'column',
|
||||||
<div style={{ position: 'absolute', top: '0', left: '50%', width: '2px', height: '100%', background: 'linear-gradient(to bottom, rgba(59, 130, 246, 0.2), transparent)' }} />
|
justifyContent: 'space-between',
|
||||||
</motion.div>
|
padding: '60px',
|
||||||
|
borderRight: '1px solid rgba(34, 211, 238, 0.2)'
|
||||||
|
}}>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: 'url("https://images.unsplash.com/photo-1587582423116-ec07293f0395?q=80&w=2070&auto=format&fit=crop")',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: 'grayscale(30%) contrast(120%)'
|
||||||
|
}} />
|
||||||
|
{/* Gradients to blend image with the tactical theme */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(to right, rgba(2, 6, 23, 0.95) 0%, rgba(2, 6, 23, 0.6) 40%, rgba(2, 6, 23, 0.9) 100%)'
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'radial-gradient(circle at 30% 50%, transparent 0%, rgba(2, 6, 23, 0.8) 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
<motion.div
|
{/* Decorative Grid & Radar (HUD elements) */}
|
||||||
key={loginStep}
|
<div style={{ position: 'absolute', inset: 0, opacity: 0.15, backgroundImage: 'linear-gradient(rgba(34, 211, 238, 0.2) 1px, transparent 1px), linear-gradient(90deg, rgba(34, 211, 238, 0.2) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{/* Content Top */}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
<div style={{ position: 'relative', zIndex: 10 }}>
|
||||||
transition={{ duration: 0.5, ease: "circOut" }}
|
|
||||||
className="login-card glass"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
|
||||||
boxShadow: '0 0 50px rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(59, 130, 246, 0.1)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="login-header">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
className="login-logo"
|
transition={{ duration: 0.8 }}
|
||||||
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '16px' }}
|
||||||
>
|
>
|
||||||
<Truck className="text-cyan-400" size={28} style={{ color: 'var(--accent-cyan)' }} />
|
<div style={{ padding: '12px', background: 'rgba(34, 211, 238, 0.1)', border: '1px solid #22d3ee', borderRadius: '12px', boxShadow: '0 0 20px rgba(34, 211, 238, 0.2)' }}>
|
||||||
|
<Truck size={32} color="#22d3ee" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: '28px', fontWeight: 900, color: '#fff', letterSpacing: '2px' }}>TELE_EMS</span>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 700, color: '#22d3ee', letterSpacing: '4px' }}>FLEET_OPERATOR</span>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="login-title" style={{ letterSpacing: '0.1em', fontWeight: 900 }}>
|
|
||||||
{loginStep === 'login' ? 'FLEET TERMINAL' : 'SECURE TOKEN'}
|
|
||||||
</h1>
|
|
||||||
<p className="login-subtitle" style={{ color: 'var(--accent-cyan)', opacity: 0.8, fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase' }}>
|
|
||||||
{loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification Required'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loginStep === 'login' ? (
|
{/* Content Middle/Bottom */}
|
||||||
<form onSubmit={handleLogin} className="login-form">
|
<div style={{ position: 'relative', zIndex: 10, maxWidth: '600px', marginBottom: '40px' }}>
|
||||||
<div className="input-group">
|
<motion.h1
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Operator ID</label>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<User className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
<input
|
style={{ fontSize: '56px', fontWeight: 900, lineHeight: 1.1, color: '#fff', marginBottom: '24px', letterSpacing: '-1px' }}
|
||||||
type="text"
|
>
|
||||||
className="login-input mono"
|
Command The Fleet.<br/>
|
||||||
placeholder="ID_ENTRY"
|
<span style={{ color: '#22d3ee', textShadow: '0 0 30px rgba(34, 211, 238, 0.4)' }}>Save Lives Faster.</span>
|
||||||
value={username}
|
</motion.h1>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
<motion.p
|
||||||
style={{ color: '#fff' }}
|
initial={{ opacity: 0 }}
|
||||||
required
|
animate={{ opacity: 1 }}
|
||||||
/>
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
|
style={{ fontSize: '18px', color: '#94a3b8', lineHeight: 1.6, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Access real-time telemetry, manage dispatch routes, and monitor critical resources from a single, secure tactical terminal.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.6 }}
|
||||||
|
style={{ display: 'flex', gap: '24px', marginTop: '40px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#22d3ee', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<Activity size={18} /> REAL-TIME SYNC
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#22d3ee', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<MapPin size={18} /> GPS TRACKING
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#22d3ee', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<ShieldCheck size={18} /> ENCRYPTED
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE - LOGIN FORM */}
|
||||||
|
<div className="fleet-login-right" style={{
|
||||||
|
width: '550px',
|
||||||
|
minWidth: '400px',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
background: '#0f172a',
|
||||||
|
boxShadow: '-20px 0 50px rgba(0,0,0,0.5)'
|
||||||
|
}}>
|
||||||
|
{/* Subtle background glow */}
|
||||||
|
<div style={{ position: 'absolute', top: '20%', right: '-10%', width: '300px', height: '300px', background: 'rgba(34, 211, 238, 0.05)', filter: 'blur(100px)', borderRadius: '50%', pointerEvents: 'none' }} />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
key={loginStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.5, ease: "circOut" }}
|
||||||
|
style={{ width: '100%', maxWidth: '420px', display: 'flex', flexDirection: 'column', gap: '32px', zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h2 style={{ fontSize: '28px', fontWeight: 800, color: '#fff', margin: '0 0 8px 0', letterSpacing: '1px' }}>
|
||||||
|
{loginStep === 'login' ? 'TERMINAL ACCESS' : 'MFA REQUIRED'}
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#22d3ee', fontSize: '13px', fontWeight: 600, letterSpacing: '2px', textTransform: 'uppercase', margin: 0, opacity: 0.8 }}>
|
||||||
|
{loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginStep === 'login' ? (
|
||||||
|
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#22d3ee', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.8 }}>Operator ID</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<User size={18} color="#22d3ee" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.8 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ID_ENTRY"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '16px 16px 16px 48px',
|
||||||
|
background: 'rgba(2, 6, 23, 0.6)', border: '1px solid rgba(34, 211, 238, 0.3)',
|
||||||
|
borderRadius: '12px', color: '#fff', fontSize: '15px', fontWeight: 500,
|
||||||
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif"
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#22d3ee'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(34, 211, 238, 0.3)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Command Key</label>
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#22d3ee', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.8 }}>Command Key</label>
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<Lock className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
<Lock size={18} color="#22d3ee" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.8 }} />
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="login-input mono"
|
placeholder="KEY_REQUIRED"
|
||||||
placeholder="KEY_REQUIRED"
|
value={password}
|
||||||
value={password}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
required
|
||||||
style={{ color: '#fff' }}
|
style={{
|
||||||
required
|
width: '100%', padding: '16px 48px 16px 48px',
|
||||||
/>
|
background: 'rgba(2, 6, 23, 0.6)', border: '1px solid rgba(34, 211, 238, 0.3)',
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer', paddingRight: '12px' }}>
|
borderRadius: '12px', color: '#fff', fontSize: '15px', fontWeight: 500,
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif", letterSpacing: showPassword ? 'normal' : '3px'
|
||||||
</button>
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#22d3ee'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(34, 211, 238, 0.3)'}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)} style={{ position: 'absolute', right: '16px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: '#22d3ee', padding: 0 }}>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="fleet-login-submit"
|
type="submit"
|
||||||
type="submit"
|
disabled={isLoading}
|
||||||
className="login-button"
|
style={{
|
||||||
disabled={isLoading}
|
marginTop: '12px', padding: '16px', background: '#22d3ee', color: '#020617',
|
||||||
style={{
|
border: 'none', borderRadius: '12px', fontSize: '15px', fontWeight: 800, letterSpacing: '1px',
|
||||||
background: 'var(--accent-cyan)',
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px',
|
||||||
color: '#000',
|
cursor: isLoading ? 'not-allowed' : 'pointer', transition: 'all 0.3s',
|
||||||
fontWeight: 900,
|
boxShadow: '0 10px 25px rgba(34, 211, 238, 0.3)'
|
||||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)'
|
}}
|
||||||
}}
|
onMouseOver={(e) => { if(!isLoading) { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 15px 35px rgba(34, 211, 238, 0.4)'; } }}
|
||||||
>
|
onMouseOut={(e) => { if(!isLoading) { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 10px 25px rgba(34, 211, 238, 0.3)'; } }}
|
||||||
{isLoading ? (
|
>
|
||||||
<Cpu className="spin" size={20} />
|
{isLoading ? (
|
||||||
) : (
|
<Cpu className="spin" size={20} />
|
||||||
<>
|
) : (
|
||||||
INITIALIZE SESSION
|
<>
|
||||||
<ArrowRight size={20} />
|
Login
|
||||||
</>
|
<ArrowRight size={20} />
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
</form>
|
</button>
|
||||||
) : (
|
</form>
|
||||||
<form onSubmit={handleMfaVerify} className="login-form">
|
) : (
|
||||||
<div className="input-group">
|
<form onSubmit={handleMfaVerify} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>TOTP Authorization</label>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#22d3ee', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.8 }}>TOTP Authorization</label>
|
||||||
<KeyRound className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
<div style={{ position: 'relative' }}>
|
||||||
<input
|
<KeyRound size={18} color="#22d3ee" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.8 }} />
|
||||||
type="text"
|
<input
|
||||||
className="login-input mono"
|
type="text"
|
||||||
placeholder="000 000"
|
placeholder="000 000"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={mfaCode}
|
value={mfaCode}
|
||||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||||
style={{ color: '#fff' }}
|
required
|
||||||
required
|
style={{
|
||||||
/>
|
width: '100%', padding: '16px 16px 16px 48px',
|
||||||
|
background: 'rgba(2, 6, 23, 0.6)', border: '1px solid rgba(34, 211, 238, 0.3)',
|
||||||
|
borderRadius: '12px', color: '#fff', fontSize: '20px', fontWeight: 600, letterSpacing: '4px', textAlign: 'center',
|
||||||
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif"
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#22d3ee'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(34, 211, 238, 0.3)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="login-button"
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
style={{
|
||||||
style={{ background: 'var(--accent-cyan)', color: '#000', fontWeight: 900 }}
|
marginTop: '12px', padding: '16px', background: '#22d3ee', color: '#020617',
|
||||||
>
|
border: 'none', borderRadius: '12px', fontSize: '15px', fontWeight: 800, letterSpacing: '1px',
|
||||||
{isLoading ? (
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px',
|
||||||
<Cpu className="spin" size={20} />
|
cursor: isLoading ? 'not-allowed' : 'pointer', transition: 'all 0.3s',
|
||||||
) : (
|
boxShadow: '0 10px 25px rgba(34, 211, 238, 0.3)'
|
||||||
<>
|
}}
|
||||||
VERIFY IDENTITY
|
>
|
||||||
<ShieldCheck size={20} />
|
{isLoading ? (
|
||||||
</>
|
<Cpu className="spin" size={20} />
|
||||||
)}
|
) : (
|
||||||
</button>
|
<>
|
||||||
</form>
|
VERIFY IDENTITY
|
||||||
)}
|
<ShieldCheck size={20} />
|
||||||
|
</>
|
||||||
<AnimatePresence>
|
)}
|
||||||
{showError && (
|
</button>
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="security-badge" style={{ color: '#ef4444', border: '1px solid rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.05)' }}>
|
</form>
|
||||||
<ShieldAlert size={14} />
|
|
||||||
<span>{showError}</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="security-badge" style={{ borderColor: 'rgba(59, 130, 246, 0.2)', background: 'rgba(59, 130, 246, 0.05)' }}>
|
<AnimatePresence>
|
||||||
<Signal size={14} color="var(--accent-cyan)" />
|
{showError && (
|
||||||
<span style={{ color: 'var(--accent-cyan)', fontWeight: 700 }}>SECURE UPLINK ESTABLISHED</span>
|
<motion.div
|
||||||
</div>
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '14px', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px', color: '#ef4444', fontSize: '13px', fontWeight: 600, marginTop: '8px' }}>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
|
<span>{showError}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="login-footer" style={{ marginTop: '24px', borderTop: '1px solid rgba(59, 130, 246, 0.1)', paddingTop: '16px', textAlign: 'center' }}>
|
|
||||||
<NavLink to="/login" style={{ color: 'var(--accent-cyan)', textDecoration: 'none', fontSize: '0.8rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', opacity: 0.7 }}>
|
|
||||||
<ArrowRight size={14} style={{ transform: 'rotate(180deg)' }} /> BACK TO STANDARD LOGIN
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Page-level status indicators */}
|
|
||||||
<div className="login-status-indicators" style={{ bottom: '40px', right: '40px' }}>
|
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(34, 211, 238, 0.1)', paddingTop: '24px', textAlign: 'center' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
|
<NavLink to="/login" style={{ color: '#94a3b8', textDecoration: 'none', fontSize: '13px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '8px', transition: 'color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.color = '#22d3ee'} onMouseOut={(e) => e.currentTarget.style.color = '#94a3b8'}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', background: 'rgba(15, 23, 42, 0.8)', padding: '8px 16px', borderRadius: '8px', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<ArrowRight size={14} style={{ transform: 'rotate(180deg)' }} /> RETURN TO STANDARD PORTAL
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--accent-cyan)' }}>COMMS_STRENGTH</span>
|
</NavLink>
|
||||||
<div style={{ display: 'flex', gap: '2px' }}>
|
|
||||||
{[1, 2, 3, 4].map(i => <div key={i} style={{ width: '3px', height: i * 3, background: 'var(--accent-cyan)' }} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-green)', fontSize: '0.7rem', fontWeight: 800 }}>
|
</motion.div>
|
||||||
<Radio size={14} className="pulse" /> LIVE TELEMETRY SYNC
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-sys-log" style={{ bottom: '40px', left: '40px', opacity: 0.3 }}>
|
{/* Embedded Global Styles to avoid breaking any other page, since we are overriding locally */}
|
||||||
<p>TERMINAL_ID: DISPATCH-X7</p>
|
<style>{`
|
||||||
<p>PROTOCOL: CS-SECURE-v4</p>
|
@keyframes spin {
|
||||||
<p>ENCRYPTION: QUANTUM-SAFE</p>
|
0% { transform: rotate(0deg); }
|
||||||
</div>
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active{
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #020617 inset !important;
|
||||||
|
-webkit-text-fill-color: white !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.fleet-login-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
.fleet-login-left {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.fleet-login-right {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,339 +1,50 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Building2, Truck, Users, CalendarDays, ClipboardCheck,
|
||||||
Truck,
|
ShoppingCart, Map, MapPin, Navigation, Link2, Activity,
|
||||||
Zap,
|
Bell, Settings, Search
|
||||||
ShieldCheck,
|
|
||||||
MapPin,
|
|
||||||
Clock,
|
|
||||||
Navigation,
|
|
||||||
AlertTriangle,
|
|
||||||
Fuel,
|
|
||||||
Gauge,
|
|
||||||
Thermometer,
|
|
||||||
Wind,
|
|
||||||
Bell,
|
|
||||||
Settings,
|
|
||||||
ChevronRight,
|
|
||||||
LayoutGrid,
|
|
||||||
Route as RouteIcon,
|
|
||||||
Users,
|
|
||||||
Search,
|
|
||||||
CheckCircle2,
|
|
||||||
ShoppingCart
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Card, StatCard } from '../components/Common';
|
|
||||||
import { fleetApi } from '../api/fleet';
|
|
||||||
import { incidentsApi } from '../api/incidents';
|
|
||||||
import type { Incident } from '../api/types';
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
// --- NEW FLEET MODULES ---
|
import { FleetOrganization } from './fleet/FleetOrganization';
|
||||||
import { FleetAssets } from './fleet/FleetAssets';
|
import { FleetAssets } from './fleet/FleetAssets';
|
||||||
import { FleetPersonnel } from './fleet/FleetPersonnel';
|
import { FleetPersonnel } from './fleet/FleetPersonnel';
|
||||||
import { FleetInventory } from './fleet/FleetInventory';
|
|
||||||
import { FleetScheduling } from './fleet/FleetScheduling';
|
import { FleetScheduling } from './fleet/FleetScheduling';
|
||||||
|
import { FleetInventory } from './fleet/FleetInventory';
|
||||||
|
|
||||||
// --- MOCK DATA FOR THE ENHANCED FEEL ---
|
const SCOPE_MODULES = [
|
||||||
const MOCK_VEHICLES = [
|
{ id: 'overview', label: 'Live Dashboard', icon: Map, desc: 'Real-time fleet tracking & telemetry' },
|
||||||
{ id: 'V001', number: 'TN-01-AM-1024', status: 'EN_ROUTE', speed: 45, fuel: 82, lat: 13.0827, lng: 80.2707, type: 'ALS' },
|
{ id: 'organization', label: 'Stations', icon: Building2, desc: 'Manage stations and profiles' },
|
||||||
{ id: 'V002', number: 'TN-05-AM-5521', status: 'IDLE', speed: 0, fuel: 65, lat: 13.0067, lng: 80.2575, type: 'BLS' },
|
{ id: 'assets', label: 'Vehicle Management', icon: Truck, desc: 'Registration, Docs, Services' },
|
||||||
{ id: 'V003', number: 'TN-07-AM-1122', status: 'TRANSPORTING', speed: 52, fuel: 45, lat: 12.9667, lng: 80.2475, type: 'TRANSFER' },
|
{ id: 'personnel', label: 'Staff Management', icon: Users, desc: 'Pilots, EMTs, Doctors' },
|
||||||
{ id: 'V004', number: 'TN-09-AM-9988', status: 'AT_SCENE', speed: 0, fuel: 78, lat: 12.9941, lng: 80.1709, type: 'AIR' },
|
{ id: 'scheduling', label: 'Crew Scheduling', icon: CalendarDays, desc: 'Shift assignments & conflicts' },
|
||||||
|
{ id: 'attendance', label: 'Attendance & Duty', icon: ClipboardCheck, desc: 'Daily attendance & payroll' },
|
||||||
|
{ id: 'inventory', label: 'Inventory Management', icon: ShoppingCart, desc: 'Stock levels & consumption' },
|
||||||
|
{ id: 'trips', label: 'Trip Management', icon: MapPin, desc: 'Active, pending, and completed trips' },
|
||||||
|
{ id: 'telematics', label: 'GPS Telematics', icon: Navigation, desc: 'Geofencing, speed, SOS alerts' },
|
||||||
|
{ id: 'referrals', label: 'Referral Network', icon: Link2, desc: 'Hospitals & specialty routing' },
|
||||||
|
{ id: 'analytics', label: 'Fleet Analytics', icon: Activity, desc: 'KPIs, SLAs, and reports' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const PERFORMANCE_DATA = [
|
const PlaceholderModule: React.FC<{ label: string; icon: React.ElementType }> = ({ label, icon: Icon }) => (
|
||||||
{ time: '08:00', trips: 12, response: 14 },
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', minHeight: '400px', color: '#94A3B8', gap: '16px' }}>
|
||||||
{ time: '10:00', trips: 18, response: 12 },
|
<Icon size={64} style={{ opacity: 0.15 }} />
|
||||||
{ time: '12:00', trips: 25, response: 18 },
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#fff', margin: 0 }}>{label}</h2>
|
||||||
{ time: '14:00', trips: 22, response: 15 },
|
<p style={{ margin: 0, opacity: 0.6 }}>This module is under active development.</p>
|
||||||
{ time: '16:00', trips: 30, response: 22 },
|
</div>
|
||||||
{ time: '18:00', trips: 28, response: 19 },
|
);
|
||||||
];
|
|
||||||
|
|
||||||
// --- LIVE MAP COMPONENT ---
|
|
||||||
const CommandMap: React.FC<{ vehicles: any[] }> = ({ vehicles }) => {
|
|
||||||
const [L, setL] = useState<any>(null);
|
|
||||||
const mapRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const loadLeaflet = () => {
|
|
||||||
const leaflet = (window as any).L;
|
|
||||||
if (leaflet) {
|
|
||||||
setL(leaflet);
|
|
||||||
if (!mapRef.current) {
|
|
||||||
const m = leaflet.map('fleet-command-map', {
|
|
||||||
zoomControl: false,
|
|
||||||
attributionControl: false
|
|
||||||
}).setView([13.0827, 80.2707], 12);
|
|
||||||
|
|
||||||
leaflet.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
maxZoom: 20
|
|
||||||
}).addTo(m);
|
|
||||||
|
|
||||||
mapRef.current = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!(window as any).L) {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
||||||
script.async = true;
|
|
||||||
script.onload = loadLeaflet;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
} else {
|
|
||||||
loadLeaflet();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (mapRef.current) {
|
|
||||||
mapRef.current.remove();
|
|
||||||
mapRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!L || !mapRef.current) return;
|
|
||||||
const map = mapRef.current;
|
|
||||||
|
|
||||||
vehicles.forEach(v => {
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'IDLE': return '#94A3B8';
|
|
||||||
case 'EN_ROUTE': return '#3B82F6';
|
|
||||||
case 'AT_SCENE': return '#F59E0B';
|
|
||||||
case 'TRANSPORTING': return '#EF4444';
|
|
||||||
case 'AT_HOSPITAL': return '#A855F7';
|
|
||||||
case 'BREAKDOWN': return '#000000';
|
|
||||||
case 'OFF_DUTY': return '#FFFFFF';
|
|
||||||
default: return '#3B82F6';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const color = getStatusColor(v.status);
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'custom-marker',
|
|
||||||
html: `<div style="background: ${color}; width: 24px; height: 24px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 0 15px ${color}; border: 2px solid #fff;">
|
|
||||||
<div style="transform: rotate(45deg); color: #fff;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-1.1 0-2 .9-2 2v9c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg></div>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [24, 24],
|
|
||||||
iconAnchor: [12, 24]
|
|
||||||
});
|
|
||||||
L.marker([v.lat, v.lng], { icon }).addTo(map).bindPopup(`<b>${v.number}</b><br/>Status: ${v.status}`);
|
|
||||||
});
|
|
||||||
}, [L, vehicles]);
|
|
||||||
|
|
||||||
return <div id="fleet-command-map" style={{ height: '100%', minHeight: '400px', borderRadius: '16px' }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FleetOperatorDashboard: React.FC = () => {
|
export const FleetOperatorDashboard: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const activeTab = searchParams.get('tab') || 'overview';
|
const activeModule = searchParams.get('tab') || 'overview';
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const activeModuleInfo = SCOPE_MODULES.find(m => m.id === activeModule);
|
||||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
|
||||||
const [vehicles, setVehicles] = useState(MOCK_VEHICLES);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const renderModuleContent = () => {
|
||||||
const fetchData = async () => {
|
switch (activeModule) {
|
||||||
try {
|
case 'organization':
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
return <FleetOrganization />;
|
||||||
const res = await incidentsApi.getIncidents({}, token);
|
|
||||||
if (res && res.data) setIncidents(res.data.slice(0, 5));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ id: 'overview', label: 'Command Center', icon: LayoutGrid },
|
|
||||||
{ id: 'assets', label: 'Fleet Assets', icon: Truck },
|
|
||||||
{ id: 'personnel', label: 'Personnel Hub', icon: Users },
|
|
||||||
{ id: 'scheduling', label: 'Mission Control', icon: Navigation },
|
|
||||||
{ id: 'inventory', label: 'Supply Chain', icon: ShoppingCart },
|
|
||||||
{ id: 'analytics', label: 'Fleet Intel', icon: Activity },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'overview':
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '24px', marginBottom: '32px' }}>
|
|
||||||
<StatCard label="Live Fleet" value="48" icon={Truck} glowColor="cyan" trend={{ value: '12%', isUp: true }} />
|
|
||||||
<StatCard label="Active Trips" value="12" icon={Activity} glowColor="green" pulse />
|
|
||||||
<StatCard label="Avg Response" value="8.4m" icon={Zap} glowColor="amber" trend={{ value: '0.5m', isUp: false }} />
|
|
||||||
<StatCard label="Compliance" value="99.2%" icon={ShieldCheck} glowColor="cyan" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
|
||||||
{/* Left Column: Map and Fleet List */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card style={{ height: '500px', padding: 0, overflow: 'hidden', border: '1px solid rgba(59, 130, 246, 0.3)', position: 'relative' }}>
|
|
||||||
<CommandMap vehicles={vehicles} />
|
|
||||||
<div style={{ position: 'absolute', top: '20px', left: '20px', zIndex: 1000 }}>
|
|
||||||
<div className="glass" style={{ padding: '12px', borderRadius: '12px', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#3B82F6' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>EN ROUTE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#94A3B8' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>IDLE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#F59E0B' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>AT SCENE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#EF4444' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>TRANSPORTING</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#A855F7' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>HOSPITAL</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#000000' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>BREAKDOWN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Live Fleet Telemetry">
|
|
||||||
<div className="table-container no-scrollbar" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)', textAlign: 'left' }}>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Vehicle</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Status</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Speed</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Fuel</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vehicles.map(v => (
|
|
||||||
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 700 }}>{v.number}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5 }}>{v.type} UNIT</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: v.status === 'IDLE' ? 'rgba(148, 163, 184, 0.1)' : 'rgba(59, 130, 246, 0.1)',
|
|
||||||
color: v.status === 'IDLE' ? '#94A3B8' : 'var(--accent-cyan)',
|
|
||||||
border: `1px solid ${v.status === 'IDLE' ? 'rgba(148, 163, 184, 0.2)' : 'rgba(59, 130, 246, 0.2)'}`
|
|
||||||
}}>{v.status}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }} className="mono">{v.speed} km/h</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ width: '100px', height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${v.fuel}%`, height: '100%', background: v.fuel < 30 ? 'var(--alert-red)' : 'var(--accent-green)' }}></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<button className="btn-ghost-sm"><Navigation size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Performance and Incidents */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card title="Operational Load">
|
|
||||||
<div style={{ height: '200px', width: '100%', position: 'relative' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
|
||||||
<AreaChart data={PERFORMANCE_DATA}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorTrips" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.3}/>
|
|
||||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: '#1E293B', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px' }}
|
|
||||||
itemStyle={{ color: 'var(--accent-cyan)' }}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="trips" stroke="var(--accent-cyan)" fillOpacity={1} fill="url(#colorTrips)" />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Active Incident Feed">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{incidents.length > 0 ? incidents.map(inc => (
|
|
||||||
<div key={inc.id} className="glass hover-glow" style={{ padding: '16px', borderRadius: '12px', borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : 'var(--accent-cyan)'}` }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
||||||
<span className="mono" style={{ fontSize: '0.8rem', fontWeight: 800 }}>#{inc.id.split('-').pop()?.toUpperCase()}</span>
|
|
||||||
<span style={{ fontSize: '0.6rem', color: 'var(--alert-red)', fontWeight: 900 }}>{inc.severity}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '4px' }}>{inc.category}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.6, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<MapPin size={12} /> {inc.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px', opacity: 0.5 }}>No active incidents</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button style={{ width: '100%', marginTop: '16px', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: 'var(--accent-cyan)', fontWeight: 700, cursor: 'pointer' }}>
|
|
||||||
VIEW ALL INCIDENTS
|
|
||||||
</button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Fleet Health Indicators">
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '12px', textAlign: 'center' }}>
|
|
||||||
<Gauge size={24} color="var(--accent-cyan)" style={{ marginBottom: '8px' }} />
|
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 800 }}>82%</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>AVAILABILITY</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '12px', textAlign: 'center' }}>
|
|
||||||
<Fuel size={24} color="var(--accent-green)" style={{ marginBottom: '8px' }} />
|
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 800 }}>94%</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>FUEL READINESS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'assets':
|
case 'assets':
|
||||||
return <FleetAssets />;
|
return <FleetAssets />;
|
||||||
case 'personnel':
|
case 'personnel':
|
||||||
@@ -342,53 +53,95 @@ export const FleetOperatorDashboard: React.FC = () => {
|
|||||||
return <FleetScheduling />;
|
return <FleetScheduling />;
|
||||||
case 'inventory':
|
case 'inventory':
|
||||||
return <FleetInventory />;
|
return <FleetInventory />;
|
||||||
|
case 'overview':
|
||||||
|
return <PlaceholderModule label="Live Fleet Dashboard" icon={Map} />;
|
||||||
|
case 'attendance':
|
||||||
|
return <PlaceholderModule label="Attendance & Duty" icon={ClipboardCheck} />;
|
||||||
|
case 'trips':
|
||||||
|
return <PlaceholderModule label="Trip Management" icon={MapPin} />;
|
||||||
|
case 'telematics':
|
||||||
|
return <PlaceholderModule label="GPS Telematics" icon={Navigation} />;
|
||||||
|
case 'referrals':
|
||||||
|
return <PlaceholderModule label="Referral Network" icon={Link2} />;
|
||||||
case 'analytics':
|
case 'analytics':
|
||||||
return <div className="glass" style={{ padding: '40px', textAlign: 'center', color: 'var(--text-secondary)' }}>Fleet Intelligence Reports Loading...</div>;
|
return <PlaceholderModule label="Fleet Analytics" icon={Activity} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return <PlaceholderModule label="Module Loading" icon={Activity} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-operator-dashboard" style={{
|
<div style={{
|
||||||
background: '#020617',
|
|
||||||
color: '#F8FAFC',
|
|
||||||
minHeight: '100vh',
|
|
||||||
fontFamily: "'Inter', sans-serif",
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
background: '#040B16',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontFamily: "'Inter', sans-serif"
|
||||||
}}>
|
}}>
|
||||||
{/* Main Content Area */}
|
{/* Top Header */}
|
||||||
<div style={{
|
<header style={{
|
||||||
flex: 1,
|
height: '72px',
|
||||||
padding: '32px',
|
padding: '0 32px',
|
||||||
maxWidth: '100%'
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.5)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
zIndex: 40,
|
||||||
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 900, color: '#fff', margin: 0, letterSpacing: '-0.5px' }}>
|
||||||
<div>
|
{activeModuleInfo?.label || 'Fleet Command'}
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 900, letterSpacing: '-0.05em', color: 'var(--accent-cyan)', textTransform: 'uppercase' }}>
|
</h1>
|
||||||
{menuItems.find(m => m.id === activeTab)?.label || 'Fleet Command'}
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', display: 'flex', alignItems: 'center', gap: '8px', marginTop: '3px' }}>
|
||||||
</h1>
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 8px #10B981' }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', opacity: 0.7 }}>
|
{activeModuleInfo?.desc || 'Secure Connection • TeleEMS Fleet'}
|
||||||
<div className="status-pulse" style={{ background: 'var(--accent-green)' }}></div>
|
|
||||||
<span style={{ fontSize: '0.875rem' }}>Strategic Operations • Live Platform Telemetry</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<div style={{ background: 'rgba(255,255,255,0.05)', padding: '8px 16px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', textTransform: 'uppercase', opacity: 0.5 }}>Tactical Time</div>
|
|
||||||
<div style={{ fontWeight: 700 }}>{new Date().toLocaleTimeString()}</div>
|
|
||||||
</div>
|
|
||||||
<Clock size={20} color="var(--accent-cyan)" />
|
|
||||||
</div>
|
|
||||||
<button className="btn-icon glass"><Bell size={20} /></button>
|
|
||||||
<button className="btn-icon glass"><Settings size={20} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderContent()}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(255,255,255,0.03)', padding: '8px 16px', borderRadius: '20px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<Search size={14} color="#64748B" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search fleet..."
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.82rem', width: '140px', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '8px', color: '#94A3B8', cursor: 'pointer', position: 'relative', display: 'flex' }}>
|
||||||
|
<Bell size={18} />
|
||||||
|
<span style={{ position: 'absolute', top: 6, right: 6, width: '7px', height: '7px', background: '#EF4444', borderRadius: '50%', border: '1px solid #040B16' }} />
|
||||||
|
</button>
|
||||||
|
<button style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '8px', color: '#94A3B8', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
<div style={{ width: '1px', height: '24px', background: 'rgba(255,255,255,0.07)' }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: '#fff' }}>Station Incharge</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#06B6D4' }}>Fleet Operator</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '34px', height: '34px', borderRadius: '10px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Users size={16} color="#fff" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '32px' }}>
|
||||||
|
<motion.div
|
||||||
|
key={activeModule}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{renderModuleContent()}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,207 +1,607 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus, Search, Truck, X, Loader2, CheckCircle,
|
||||||
Search,
|
AlertCircle, ChevronDown, Edit2
|
||||||
Filter,
|
|
||||||
Truck,
|
|
||||||
FileText,
|
|
||||||
Wrench,
|
|
||||||
Calendar,
|
|
||||||
AlertTriangle,
|
|
||||||
ExternalLink,
|
|
||||||
ChevronRight,
|
|
||||||
ShieldCheck,
|
|
||||||
Fuel,
|
|
||||||
Gauge
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
interface Station {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Vehicle {
|
interface Vehicle {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
registration_number: string;
|
||||||
type: 'ALS' | 'BLS' | 'TRANSPORT';
|
vehicle_type: string;
|
||||||
model: string;
|
brand?: string;
|
||||||
station: string;
|
model?: string;
|
||||||
status: 'ACTIVE' | 'MAINTENANCE' | 'BREAKDOWN' | 'OFF_DUTY';
|
chassis_number?: string;
|
||||||
docs: {
|
station_id?: string;
|
||||||
rc: string;
|
status?: string;
|
||||||
fc: string;
|
|
||||||
insurance: string;
|
|
||||||
permit: string;
|
|
||||||
};
|
|
||||||
lastService: string;
|
|
||||||
nextService: string;
|
|
||||||
fuel: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_FLEET: Vehicle[] = [
|
interface VehicleForm {
|
||||||
{ id: 'V-001', number: 'KA 01 MG 2341', type: 'ALS', model: 'Force Traveller 2024', station: 'ALPHA-NODE-01', status: 'ACTIVE', docs: { rc: 'VALID', fc: 'EXPIRING_SOON', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-04-10', nextService: '2026-07-10', fuel: 85 },
|
registration_number: string;
|
||||||
{ id: 'V-002', number: 'KA 51 BH 9921', type: 'BLS', model: 'Tata Winger 2023', station: 'BETA-HUB-04', status: 'MAINTENANCE', docs: { rc: 'VALID', fc: 'VALID', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-05-01', nextService: '2026-08-01', fuel: 42 },
|
vehicle_type: string;
|
||||||
{ id: 'V-003', number: 'KA 03 AA 1122', type: 'ALS', model: 'Force Traveller 2023', station: 'ALPHA-NODE-01', status: 'BREAKDOWN', docs: { rc: 'VALID', fc: 'VALID', insurance: 'EXPIRING_SOON', permit: 'VALID' }, lastService: '2026-02-15', nextService: '2026-05-15', fuel: 0 },
|
chassis_number: string;
|
||||||
{ id: 'V-004', number: 'KA 05 MN 5678', type: 'TRANSPORT', model: 'Maruti Eeco 2022', station: 'GAMMA-STATION-02', status: 'ACTIVE', docs: { rc: 'VALID', fc: 'VALID', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-03-20', nextService: '2026-06-20', fuel: 92 },
|
brand: string;
|
||||||
];
|
model: string;
|
||||||
|
station_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VEHICLE_TYPES = ['ALS', 'BLS', 'TRANSPORT', 'ICU', 'NEONATAL'];
|
||||||
|
|
||||||
|
const EMPTY_FORM: VehicleForm = {
|
||||||
|
registration_number: '',
|
||||||
|
vehicle_type: 'ALS',
|
||||||
|
chassis_number: '',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
station_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#64748B',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
padding: '11px 14px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: 'pointer',
|
||||||
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
export const FleetAssets: React.FC = () => {
|
export const FleetAssets: React.FC = () => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||||
|
const [stations, setStations] = useState<Station[]>([]);
|
||||||
|
const [stationsLoading, setStationsLoading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [form, setForm] = useState<VehicleForm>(EMPTY_FORM);
|
||||||
|
const [editForm, setEditForm] = useState<VehicleForm>(EMPTY_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', message: string) => {
|
||||||
|
setToast({ type, message });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch stations for the dropdown
|
||||||
|
const fetchStations = useCallback(async () => {
|
||||||
|
setStationsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/stations', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
let list: Station[] = [];
|
||||||
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
setStations(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch stations:', e);
|
||||||
|
} finally {
|
||||||
|
setStationsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Fetch registered vehicles — fleet operator endpoint
|
||||||
|
const fetchVehicles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
let list: Vehicle[] = [];
|
||||||
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
setVehicles(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch vehicles:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchVehicles(); fetchStations(); }, [fetchVehicles, fetchStations]);
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (v: Vehicle) => {
|
||||||
|
setEditingVehicle(v);
|
||||||
|
setEditForm({
|
||||||
|
registration_number: v.registration_number || '',
|
||||||
|
vehicle_type: v.vehicle_type || 'ALS',
|
||||||
|
chassis_number: v.chassis_number || '',
|
||||||
|
brand: v.brand || '',
|
||||||
|
model: v.model || '',
|
||||||
|
station_id: v.station_id || '',
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingVehicle) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
registration_number: editForm.registration_number,
|
||||||
|
vehicle_type: editForm.vehicle_type,
|
||||||
|
station_id: editForm.station_id || undefined,
|
||||||
|
chassis_number: editForm.chassis_number || undefined,
|
||||||
|
brand: editForm.brand,
|
||||||
|
model: editForm.model,
|
||||||
|
};
|
||||||
|
const res = await fetch(
|
||||||
|
`https://teleems-api-gateway.onrender.com/v1/fleet/vehicles/${editingVehicle.id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
console.log('[Update Vehicle] Response:', json);
|
||||||
|
if (res.ok || json?.id || json?.data?.id) {
|
||||||
|
showToast('success', 'Vehicle updated successfully!');
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingVehicle(null);
|
||||||
|
fetchVehicles();
|
||||||
|
} else {
|
||||||
|
showToast('error', json?.message || `Error ${res.status}: Failed to update vehicle.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.station_id) {
|
||||||
|
showToast('error', 'Please select a station.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
registration_number: form.registration_number,
|
||||||
|
vehicle_type: form.vehicle_type,
|
||||||
|
station_id: form.station_id,
|
||||||
|
chassis_number: form.chassis_number || undefined,
|
||||||
|
brand: form.brand,
|
||||||
|
model: form.model,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
console.log('[Register Vehicle] Response:', json);
|
||||||
|
|
||||||
|
if (res.ok || res.status === 201 || json?.id || json?.data?.id) {
|
||||||
|
showToast('success', `Vehicle "${form.registration_number}" registered successfully!`);
|
||||||
|
setShowModal(false);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
fetchVehicles();
|
||||||
|
} else {
|
||||||
|
showToast('error', json?.message || `Error ${res.status}: Failed to register vehicle.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = vehicles.filter(v =>
|
||||||
|
v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.model?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusColor = (status?: string) => {
|
||||||
|
if (status === 'ACTIVE') return { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.2)', color: '#22C55E' };
|
||||||
|
if (status === 'BREAKDOWN') return { bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.2)', color: '#EF4444' };
|
||||||
|
return { bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.2)', color: '#F59E0B' };
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-assets animate-in fade-in duration-500">
|
<div style={{ color: '#F8FAFC', position: 'relative' }}>
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||||
|
exit={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: '24px', left: '50%', zIndex: 9999,
|
||||||
|
padding: '14px 24px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
|
background: toast.type === 'success' ? 'rgba(16,185,129,0.15)' : 'rgba(239,68,68,0.15)',
|
||||||
|
border: `1px solid ${toast.type === 'success' ? 'rgba(16,185,129,0.4)' : 'rgba(239,68,68,0.4)'}`,
|
||||||
|
color: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||||
|
fontWeight: 600, fontSize: '0.875rem', backdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Register Vehicle Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#0F172A', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '20px', padding: '32px', width: '560px', maxWidth: '95vw', maxHeight: '92vh', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.4rem', fontWeight: 900, color: '#fff', margin: 0 }}>Register New Vehicle</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', margin: '4px 0 0' }}>Add an ambulance to your fleet</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowModal(false)} style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '8px', padding: '8px', color: '#94A3B8', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||||||
|
|
||||||
|
{/* Registration Number */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Registration Number *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. KA-01-EMS-1234"
|
||||||
|
required
|
||||||
|
value={form.registration_number}
|
||||||
|
onChange={e => setForm(f => ({ ...f, registration_number: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle Type */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Vehicle Type *</label>
|
||||||
|
<select
|
||||||
|
style={selectStyle}
|
||||||
|
required
|
||||||
|
value={form.vehicle_type}
|
||||||
|
onChange={e => setForm(f => ({ ...f, vehicle_type: e.target.value }))}
|
||||||
|
>
|
||||||
|
{VEHICLE_TYPES.map(t => (
|
||||||
|
<option key={t} value={t} style={{ background: '#0F172A' }}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} style={{ position: 'absolute', right: '14px', top: '38px', color: '#64748B', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign to Station */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Assign to Station *</label>
|
||||||
|
{stationsLoading ? (
|
||||||
|
<div style={{ ...inputStyle, display: 'flex', alignItems: 'center', gap: '8px', color: '#64748B' }}>
|
||||||
|
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> Loading stations...
|
||||||
|
</div>
|
||||||
|
) : stations.length === 0 ? (
|
||||||
|
<div style={{ ...inputStyle, color: '#EF4444', fontSize: '0.8rem' }}>
|
||||||
|
⚠ No stations found. Please create a station first.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
style={selectStyle}
|
||||||
|
required
|
||||||
|
value={form.station_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, station_id: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="" style={{ background: '#0F172A' }}>— Select a Station —</option>
|
||||||
|
{stations.map(s => (
|
||||||
|
<option key={s.id} value={s.id} style={{ background: '#0F172A' }}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} style={{ position: 'absolute', right: '14px', top: '38px', color: '#64748B', pointerEvents: 'none' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand & Model */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Brand *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. Force Motors"
|
||||||
|
required
|
||||||
|
value={form.brand}
|
||||||
|
onChange={e => setForm(f => ({ ...f, brand: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Model *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. Traveller"
|
||||||
|
required
|
||||||
|
value={form.model}
|
||||||
|
onChange={e => setForm(f => ({ ...f, model: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chassis Number */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Chassis Number</label>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, fontFamily: 'monospace' }}
|
||||||
|
placeholder="e.g. CH-998877"
|
||||||
|
value={form.chassis_number}
|
||||||
|
onChange={e => setForm(f => ({ ...f, chassis_number: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
|
<button
|
||||||
|
type="button" onClick={() => setShowModal(false)}
|
||||||
|
style={{ flex: 1, padding: '13px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', color: '#94A3B8', cursor: 'pointer', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit" disabled={submitting}
|
||||||
|
style={{ flex: 2, padding: '13px', background: submitting ? 'rgba(6,182,212,0.3)' : 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', cursor: submitting ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '0.875rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', boxShadow: '0 4px 16px rgba(6,182,212,0.3)' }}
|
||||||
|
>
|
||||||
|
{submitting ? <><Loader2 size={18} className="spin" /> Registering...</> : <><Plus size={18} /> REGISTER VEHICLE</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Edit Vehicle Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showEditModal && editingVehicle && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowEditModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#0F172A', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '20px', padding: '32px', width: '560px', maxWidth: '95vw', maxHeight: '92vh', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.4rem', fontWeight: 900, color: '#fff', margin: 0 }}>Edit Vehicle</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', margin: '4px 0 0' }}>Update details for {editingVehicle.registration_number}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowEditModal(false)} style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '8px', padding: '8px', color: '#94A3B8', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdate}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Registration Number *</label>
|
||||||
|
<input style={inputStyle} required value={editForm.registration_number}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, registration_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Vehicle Type *</label>
|
||||||
|
<select style={selectStyle} required value={editForm.vehicle_type}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, vehicle_type: e.target.value }))}>
|
||||||
|
{VEHICLE_TYPES.map(t => <option key={t} value={t} style={{ background: '#0F172A' }}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} style={{ position: 'absolute', right: '14px', top: '38px', color: '#64748B', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Assign to Station</label>
|
||||||
|
{stationsLoading ? (
|
||||||
|
<div style={{ ...inputStyle, display: 'flex', alignItems: 'center', gap: '8px', color: '#64748B' }}>
|
||||||
|
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> Loading stations...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select style={selectStyle} value={editForm.station_id}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, station_id: e.target.value }))}>
|
||||||
|
<option value="" style={{ background: '#0F172A' }}>— Select a Station —</option>
|
||||||
|
{stations.map(s => <option key={s.id} value={s.id} style={{ background: '#0F172A' }}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} style={{ position: 'absolute', right: '14px', top: '38px', color: '#64748B', pointerEvents: 'none' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Brand *</label>
|
||||||
|
<input style={inputStyle} required value={editForm.brand}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, brand: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Model *</label>
|
||||||
|
<input style={inputStyle} required value={editForm.model}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, model: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Chassis Number</label>
|
||||||
|
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={editForm.chassis_number}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, chassis_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
|
<button type="button" onClick={() => setShowEditModal(false)}
|
||||||
|
style={{ flex: 1, padding: '13px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', color: '#94A3B8', cursor: 'pointer', fontWeight: 600 }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting}
|
||||||
|
style={{ flex: 2, padding: '13px', background: submitting ? 'rgba(6,182,212,0.3)' : 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', cursor: submitting ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '0.875rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', boxShadow: '0 4px 16px rgba(6,182,212,0.3)' }}>
|
||||||
|
{submitting ? <><Loader2 size={18} className="spin" /> Updating...</> : <><Edit2 size={18} /> UPDATE VEHICLE</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(255,255,255,0.03)', padding: '8px 16px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
<div className="glass" style={{ padding: '4px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
<Search size={16} color="#64748B" />
|
||||||
<Search size={16} style={{ opacity: 0.5 }} />
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
placeholder="Search vehicles..."
|
||||||
placeholder="Search by vehicle number or model..."
|
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.875rem', outline: 'none', width: '240px' }}
|
||||||
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.875rem', padding: '8px 0', width: '300px', outline: 'none' }}
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="btn-icon glass"><Filter size={18} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<button
|
||||||
|
onClick={openModal}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '11px 22px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', fontWeight: 700, cursor: 'pointer', boxShadow: '0 4px 12px rgba(6,182,212,0.3)', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
<Plus size={18} /> REGISTER NEW VEHICLE
|
<Plus size={18} /> REGISTER NEW VEHICLE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
{/* Loading */}
|
||||||
{/* Fleet Inventory Grid */}
|
{loading && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '200px', gap: '12px', color: '#64748B' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
<Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
{MOCK_FLEET.map((v) => (
|
<span>Loading vehicles...</span>
|
||||||
<Card
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
|
||||||
|
<Truck size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#94A3B8', marginBottom: '8px' }}>No Vehicles Registered</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem' }}>Click "Register New Vehicle" to add your first ambulance.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vehicle Cards */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||||
|
{filtered.map(v => {
|
||||||
|
const sc = getStatusColor(v.status);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setSelectedVehicle(v)}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
style={{
|
animate={{ opacity: 1, y: 0 }}
|
||||||
cursor: 'pointer',
|
style={{ ...cardStyle, position: 'relative', overflow: 'hidden', transition: 'transform 0.2s, box-shadow 0.2s', cursor: 'pointer' }}
|
||||||
border: selectedVehicle?.id === v.id ? '2px solid var(--accent-cyan)' : '1px solid var(--card-border)',
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 32px rgba(6,182,212,0.1)'; }}
|
||||||
background: selectedVehicle?.id === v.id ? 'rgba(59, 130, 246, 0.05)' : 'rgba(15, 23, 42, 0.4)'
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '4px', height: '100%', background: sc.color, borderRadius: '4px 0 0 4px' }} />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: 'var(--accent-cyan)', marginBottom: '4px' }}>{v.type} UNIT • {v.id}</div>
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#06B6D4', marginBottom: '4px' }}>{v.vehicle_type} UNIT</div>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 800 }}>{v.number}</h3>
|
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#fff', margin: 0 }}>{v.registration_number}</h3>
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.5 }}>{v.model}</div>
|
{(v.brand || v.model) && (
|
||||||
</div>
|
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginTop: '4px' }}>{v.brand} {v.model}</div>
|
||||||
<div style={{
|
)}
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
background: v.status === 'ACTIVE' ? 'rgba(34, 197, 94, 0.1)' : v.status === 'BREAKDOWN' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(245, 158, 11, 0.1)',
|
|
||||||
color: v.status === 'ACTIVE' ? '#22C55E' : v.status === 'BREAKDOWN' ? '#EF4444' : '#F59E0B',
|
|
||||||
border: `1px solid ${v.status === 'ACTIVE' ? 'rgba(34, 197, 94, 0.2)' : v.status === 'BREAKDOWN' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(245, 158, 11, 0.2)'}`
|
|
||||||
}}>
|
|
||||||
{v.status}
|
|
||||||
</div>
|
</div>
|
||||||
|
{v.status && (
|
||||||
|
<div style={{ padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 900, background: sc.bg, color: sc.color, border: `1px solid ${sc.border}` }}>
|
||||||
|
{v.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '16px' }}>
|
{v.chassis_number && (
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace', marginBottom: '12px' }}>
|
||||||
<div style={{ fontSize: '0.6rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>Fuel Level</div>
|
CH: {v.chassis_number}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1, height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${v.fuel}%`, height: '100%', background: v.fuel < 25 ? '#EF4444' : '#22C55E' }}></div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>{v.fuel}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
<button
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
onClick={e => { e.stopPropagation(); openEditModal(v); }}
|
||||||
<div className={v.docs.fc === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Fitness Certificate"></div>
|
style={{ width: '100%', padding: '9px', background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)', borderRadius: '8px', color: '#06B6D4', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', marginTop: '4px' }}
|
||||||
<div className={v.docs.insurance === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Insurance"></div>
|
>
|
||||||
<div className={v.docs.permit === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Ambulance Permit"></div>
|
<Edit2 size={13} /> EDIT VEHICLE
|
||||||
</div>
|
</button>
|
||||||
<span style={{ fontSize: '0.7rem', opacity: 0.5 }}>{v.station}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Inspector Panel */}
|
|
||||||
<div style={{ position: 'sticky', top: '0' }}>
|
|
||||||
{selectedVehicle ? (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={selectedVehicle.id}
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
>
|
|
||||||
<Card title="Asset Intelligence" subtitle={`Detailed diagnostics for ${selectedVehicle.number}`}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
{/* Critical Document Status */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--accent-cyan)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<FileText size={14} /> Document Vault
|
|
||||||
</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{[
|
|
||||||
{ label: 'Registration (RC)', status: selectedVehicle.docs.rc, expiry: '2030-12-15' },
|
|
||||||
{ label: 'Fitness (FC)', status: selectedVehicle.docs.fc, expiry: '2026-06-01' },
|
|
||||||
{ label: 'Insurance Policy', status: selectedVehicle.docs.insurance, expiry: '2026-05-15' },
|
|
||||||
{ label: 'Ambulance Permit', status: selectedVehicle.docs.permit, expiry: '2026-09-20' },
|
|
||||||
].map((doc, idx) => (
|
|
||||||
<div key={idx} style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.8125rem', fontWeight: 600 }}>{doc.label}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5 }}>Expires: {doc.expiry}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: doc.status === 'VALID' ? '#22C55E' : '#F59E0B', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
{doc.status === 'VALID' ? <ShieldCheck size={16} /> : <AlertTriangle size={16} />}
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 900 }}>{doc.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Maintenance History */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--accent-cyan)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Wrench size={14} /> Service Records
|
|
||||||
</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(59, 130, 246, 0.05)', border: '1px solid rgba(59, 130, 246, 0.1)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>Upcoming Service</span>
|
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)' }}>{selectedVehicle.nextService}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.6 }}>Scheduled for: Engine Oil change, Brake pad inspection, and AC filter cleaning.</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<div style={{ fontSize: '0.75rem' }}>Last Major Service</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 700 }}>{selectedVehicle.lastService}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
||||||
<button className="btn-ghost" style={{ width: '100%', fontSize: '0.75rem' }}>VIEW ALL RECORDS</button>
|
|
||||||
<button className="btn-primary" style={{ width: '100%', fontSize: '0.75rem' }}>LOG MAINTENANCE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
);
|
||||||
) : (
|
})}
|
||||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px', color: 'var(--accent-cyan)' }}>
|
|
||||||
<Truck size={24} />
|
|
||||||
</div>
|
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>No Asset Selected</h3>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>Select a vehicle from the fleet inventory to view tactical diagnostics, documents, and service history.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spin { animation: spin 1s linear infinite; }`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
642
src/pages/fleet/FleetOrganization.tsx
Normal file
642
src/pages/fleet/FleetOrganization.tsx
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import {
|
||||||
|
Building2, MapPin, Phone, Plus, Search, MoreVertical,
|
||||||
|
Edit, Users, Truck, ShieldCheck, Globe, X, Loader2,
|
||||||
|
CheckCircle, AlertCircle, Navigation
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix default marker icons for Leaflet bundled via Vite
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Station {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
gps_lat?: number;
|
||||||
|
gps_lon?: number;
|
||||||
|
incharge_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
vehiclesAssigned?: number;
|
||||||
|
staffAssigned?: number;
|
||||||
|
status?: 'ACTIVE' | 'INACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StationForm {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
incharge_name: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#64748B',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
padding: '11px 14px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: StationForm = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
incharge_name: '',
|
||||||
|
phone: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FleetOrganization: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'stations' | 'profile'>('stations');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [stations, setStations] = useState<Station[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingStationId, setEditingStationId] = useState<string | null>(null);
|
||||||
|
const [loadingDetailsId, setLoadingDetailsId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<StationForm>(EMPTY_FORM);
|
||||||
|
const [gpsCoords, setGpsCoords] = useState<{ lat: number; lon: number } | null>(null);
|
||||||
|
const [gpsLoading, setGpsLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
const markerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
// Get organisationId from stored user
|
||||||
|
const user = (() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('teleems_user') || '{}'); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
const organisationId: string = user?.metadata?.organisation?.id || user?.organisationId || '';
|
||||||
|
|
||||||
|
const fetchStations = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError('');
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!authToken) {
|
||||||
|
setFetchError('Session expired — no auth token found. Please log out and log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = 'https://teleems-api-gateway.onrender.com/v1/fleet/stations';
|
||||||
|
console.log('[Stations] Calling:', url, '| token starts with:', authToken.substring(0, 15));
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Stations] HTTP status:', res.status);
|
||||||
|
const json = await res.json();
|
||||||
|
console.log('[Stations] Response:', json);
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
setFetchError(`Session expired (${res.status}). Please log out and log in again to refresh your token.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setFetchError(`API error ${res.status}: ${json?.message || 'Unknown error'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list: Station[] = [];
|
||||||
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
|
||||||
|
setStations(list);
|
||||||
|
} catch (e) {
|
||||||
|
setFetchError('Network error — unable to reach the server. Check your connection.');
|
||||||
|
console.error('Failed to fetch stations:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStations(); }, [fetchStations]);
|
||||||
|
|
||||||
|
// Refetch when user navigates back to the tab
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisible = () => { if (document.visibilityState === 'visible') fetchStations(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
}, [fetchStations]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize Leaflet map when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showModal) {
|
||||||
|
// Destroy map on close
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wait for DOM
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current || mapInstanceRef.current) return;
|
||||||
|
const initialLat = gpsCoords ? gpsCoords.lat : 20.5937;
|
||||||
|
const initialLon = gpsCoords ? gpsCoords.lon : 78.9629;
|
||||||
|
const initialZoom = gpsCoords ? 14 : 5;
|
||||||
|
|
||||||
|
const map = L.map(mapContainerRef.current, { zoomControl: true }).setView([initialLat, initialLon], initialZoom);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
if (gpsCoords) {
|
||||||
|
markerRef.current = L.marker([initialLat, initialLon]).addTo(map)
|
||||||
|
.bindPopup(`📍 ${initialLat.toFixed(6)}, ${initialLon.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
setGpsCoords({ lat, lon: lng });
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng([lat, lng]);
|
||||||
|
} else {
|
||||||
|
markerRef.current = L.marker([lat, lng]).addTo(map)
|
||||||
|
.bindPopup(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showModal, gpsCoords]);
|
||||||
|
|
||||||
|
const flyToCurrentLocation = () => {
|
||||||
|
setGpsLoading(true);
|
||||||
|
if (!navigator.geolocation) { setGpsLoading(false); return; }
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const { latitude: lat, longitude: lon } = pos.coords;
|
||||||
|
setGpsCoords({ lat, lon });
|
||||||
|
setGpsLoading(false);
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.flyTo([lat, lon], 15);
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng([lat, lon]);
|
||||||
|
} else {
|
||||||
|
markerRef.current = L.marker([lat, lon]).addTo(mapInstanceRef.current!)
|
||||||
|
.bindPopup(`📍 ${lat.toFixed(6)}, ${lon.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => setGpsLoading(false),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingStationId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setGpsCoords(null);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = async (station: Station) => {
|
||||||
|
setLoadingDetailsId(station.id);
|
||||||
|
try {
|
||||||
|
const res = await fleetApi.getStationDetails(station.id, token);
|
||||||
|
console.log('[Stations] Fetched single station details:', res);
|
||||||
|
|
||||||
|
const details = res?.data || res?.station || res || station;
|
||||||
|
|
||||||
|
setEditingStationId(station.id);
|
||||||
|
setForm({
|
||||||
|
name: details.name || station.name,
|
||||||
|
address: details.address || station.address,
|
||||||
|
incharge_name: details.incharge_name || station.incharge_name || '',
|
||||||
|
phone: details.phone || station.phone || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lat = details.gps_lat !== undefined ? details.gps_lat : station.gps_lat;
|
||||||
|
const lon = details.gps_lon !== undefined ? details.gps_lon : station.gps_lon;
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
setGpsCoords({ lat: Number(lat), lon: Number(lon) });
|
||||||
|
} else {
|
||||||
|
setGpsCoords(null);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch station details, falling back to card data:', err);
|
||||||
|
// Fallback to local card data so the UI doesn't break
|
||||||
|
setEditingStationId(station.id);
|
||||||
|
setForm({
|
||||||
|
name: station.name,
|
||||||
|
address: station.address,
|
||||||
|
incharge_name: station.incharge_name || '',
|
||||||
|
phone: station.phone || '',
|
||||||
|
});
|
||||||
|
if (station.gps_lat && station.gps_lon) {
|
||||||
|
setGpsCoords({ lat: Number(station.gps_lat), lon: Number(station.gps_lon) });
|
||||||
|
} else {
|
||||||
|
setGpsCoords(null);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
} finally {
|
||||||
|
setLoadingDetailsId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', message: string) => {
|
||||||
|
setToast({ type, message });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
address: form.address,
|
||||||
|
gps_lat: gpsCoords?.lat ?? 0,
|
||||||
|
gps_lon: gpsCoords?.lon ?? 0,
|
||||||
|
incharge_name: form.incharge_name,
|
||||||
|
phone: form.phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (editingStationId) {
|
||||||
|
res = await fleetApi.updateStation(editingStationId, payload, token);
|
||||||
|
} else {
|
||||||
|
res = await fleetApi.createStation(payload, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res && (res.status === 200 || res.status === 201 || res.id || res.success || res.data)) {
|
||||||
|
showToast('success', editingStationId ? `Station "${form.name}" updated successfully!` : `Station "${form.name}" created successfully!`);
|
||||||
|
setShowModal(false);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setGpsCoords(null);
|
||||||
|
setEditingStationId(null);
|
||||||
|
fetchStations();
|
||||||
|
} else {
|
||||||
|
showToast('error', res?.message || `Failed to ${editingStationId ? 'update' : 'create'} station. Please try again.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredStations = stations.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.incharge_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#F8FAFC', position: 'relative' }}>
|
||||||
|
{/* Toast Notification */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||||
|
exit={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: '24px', left: '50%', zIndex: 9999,
|
||||||
|
padding: '14px 24px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
|
background: toast.type === 'success' ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
||||||
|
border: `1px solid ${toast.type === 'success' ? 'rgba(16,185,129,0.4)' : 'rgba(239,68,68,0.4)'}`,
|
||||||
|
color: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||||
|
fontWeight: 600, fontSize: '0.875rem',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Add Station Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#0F172A', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '20px', padding: '24px 32px', width: '880px', maxWidth: '95vw', maxHeight: '96vh', overflowY: 'hidden', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.3rem', fontWeight: 900, color: '#fff', margin: 0 }}>{editingStationId ? 'Edit Station' : 'Add New Station'}</h2>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#64748B', margin: '2px 0 0' }}>{editingStationId ? 'Update dispatch station details' : 'Create a new dispatch station'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setShowModal(false)} style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '8px', padding: '8px', color: '#94A3B8', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: '28px', alignItems: 'start' }}>
|
||||||
|
|
||||||
|
{/* Left Column: Form Fields */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Station Name *</label>
|
||||||
|
<input style={inputStyle} placeholder="e.g. Alpha Dispatch Hub" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Address *</label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none', height: '64px' }} placeholder="Full address of the station" required value={form.address} onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Incharge Name *</label>
|
||||||
|
<input style={inputStyle} placeholder="Station incharge full name" required value={form.incharge_name} onChange={e => setForm(f => ({ ...f, incharge_name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Contact Phone *</label>
|
||||||
|
<input style={inputStyle} placeholder="+91-9876543210" required value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Interactive Map Picker */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>GPS Location — Click on map to set pin</label>
|
||||||
|
<div style={{ position: 'relative', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ height: '210px', width: '100%', background: '#1E293B' }} />
|
||||||
|
{/* Overlay UI */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={flyToCurrentLocation}
|
||||||
|
disabled={gpsLoading}
|
||||||
|
style={{ position: 'absolute', top: '10px', right: '10px', zIndex: 1000, padding: '6px 12px', background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(6,182,212,0.4)', borderRadius: '8px', color: '#06B6D4', cursor: gpsLoading ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.7rem', fontWeight: 600, backdropFilter: 'blur(8px)' }}
|
||||||
|
>
|
||||||
|
{gpsLoading ? <Loader2 size={12} className="spin" /> : <Navigation size={12} />}
|
||||||
|
{gpsLoading ? 'Locating...' : 'My Location'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderRadius: '8px', background: gpsCoords ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.03)', border: `1px solid ${gpsCoords ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.06)'}`, color: gpsCoords ? '#10B981' : '#64748B', fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||||
|
{gpsCoords ? `Lat: ${gpsCoords.lat.toFixed(6)} | Lon: ${gpsCoords.lon.toFixed(6)}` : '📍 Click on the map to place a pin'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons placed cleanly inside the right column at the bottom */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
|
||||||
|
<button type="button" onClick={() => setShowModal(false)}
|
||||||
|
style={{ flex: 1, padding: '11px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', color: '#94A3B8', cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting}
|
||||||
|
style={{ flex: 1.5, padding: '11px', background: submitting ? 'rgba(6,182,212,0.3)' : 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', cursor: submitting ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '0.85rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', boxShadow: '0 4px 16px rgba(6,182,212,0.3)' }}>
|
||||||
|
{submitting ? (
|
||||||
|
<><Loader2 size={16} className="spin" /> {editingStationId ? 'Updating...' : 'Creating...'}</>
|
||||||
|
) : (
|
||||||
|
<><Edit size={16} /> {editingStationId ? 'UPDATE' : 'CREATE'}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Toggle */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '28px', background: 'rgba(255,255,255,0.03)', padding: '4px', borderRadius: '12px', width: 'fit-content', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
{(['stations', 'profile'] as const).map(tab => (
|
||||||
|
<button key={tab} onClick={() => setActiveTab(tab)} style={{ padding: '10px 24px', borderRadius: '10px', border: 'none', background: activeTab === tab ? 'linear-gradient(135deg, #06B6D4, #3B82F6)' : 'transparent', color: activeTab === tab ? '#fff' : '#64748B', fontWeight: activeTab === tab ? 700 : 500, fontSize: '0.875rem', cursor: 'pointer', transition: 'all 0.3s ease', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: activeTab === tab ? '0 4px 12px rgba(6,182,212,0.3)' : 'none' }}>
|
||||||
|
{tab === 'stations' ? <><MapPin size={16} /> Station Management</> : <><Building2 size={16} /> Organisation Profile</>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'profile' ? (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, marginBottom: '24px', color: '#fff' }}>Fleet Operator Profile</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||||
|
<div><label style={labelStyle}>Full Name</label><input style={inputStyle} defaultValue={user?.name || ''} placeholder="Full name" /></div>
|
||||||
|
<div><label style={labelStyle}>Username</label><input style={{ ...inputStyle, fontFamily: 'monospace', color: '#06B6D4' }} defaultValue={user?.username || ''} readOnly /></div>
|
||||||
|
<div><label style={labelStyle}>Email Address</label><input style={inputStyle} type="email" defaultValue={user?.email || ''} placeholder="Email" /></div>
|
||||||
|
<div><label style={labelStyle}>Phone Number</label><input style={inputStyle} defaultValue={user?.phone || ''} placeholder="+91-XXXXXXXXXX" /></div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}><label style={labelStyle}>Organisation ID</label><input style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.78rem', color: '#94A3B8' }} defaultValue={user?.organisationId || organisationId || ''} readOnly /></div>
|
||||||
|
{user?.department && <div><label style={labelStyle}>Department</label><input style={inputStyle} defaultValue={user.department} /></div>}
|
||||||
|
{user?.designation && <div><label style={labelStyle}>Designation</label><input style={inputStyle} defaultValue={user.designation} /></div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button style={{ padding: '12px 28px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '10px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>SAVE CHANGES</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div style={{ ...cardStyle, textAlign: 'center' }}>
|
||||||
|
<div style={{ width: '72px', height: '72px', borderRadius: '50%', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px', fontSize: '1.5rem', fontWeight: 900, color: '#fff', boxShadow: '0 0 24px rgba(6,182,212,0.3)' }}>
|
||||||
|
{(user?.name || 'FO').split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800 }}>{user?.name || 'Fleet Operator'}</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', marginTop: '4px' }}>{user?.email || ''}</p>
|
||||||
|
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<div style={{ padding: '8px', background: user?.status === 'ACTIVE' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)', border: `1px solid ${user?.status === 'ACTIVE' ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'}`, borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: user?.status === 'ACTIVE' ? '#10B981' : '#EF4444', fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
<ShieldCheck size={14} /> {user?.status || 'ACTIVE'}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px', background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.12)', borderRadius: '8px', color: '#475569', fontSize: '0.68rem', fontFamily: 'monospace', wordBreak: 'break-all', textAlign: 'left' }}>
|
||||||
|
ORG: {user?.organisationId || organisationId || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h4 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '16px', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Quick Stats</h4>
|
||||||
|
{[{ icon: MapPin, label: 'Active Stations', value: stations.length || 0 }, { icon: Truck, label: 'Total Vehicles', value: 25 }, { icon: Users, label: 'Total Staff', value: 71 }].map(({ icon: Icon, label, value }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#94A3B8', fontSize: '0.875rem' }}><Icon size={16} color="#06B6D4" /> {label}</div>
|
||||||
|
<span style={{ fontWeight: 900, fontSize: '1.25rem', color: '#fff' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', background: 'rgba(255,255,255,0.03)', padding: '8px 16px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<Search size={16} color="#64748B" />
|
||||||
|
<input type="text" placeholder="Search stations..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.875rem', outline: 'none', width: '220px' }} />
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreateModal}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '11px 22px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', fontWeight: 700, cursor: 'pointer', boxShadow: '0 4px 12px rgba(6,182,212,0.3)', fontSize: '0.875rem' }}>
|
||||||
|
<Plus size={18} /> ADD NEW STATION
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '200px', gap: '12px', color: '#64748B' }}>
|
||||||
|
<Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
<span>Loading stations...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{!loading && fetchError && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 24px', background: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: '16px', marginBottom: '20px' }}>
|
||||||
|
<AlertCircle size={36} color="#EF4444" style={{ marginBottom: '12px', opacity: 0.8 }} />
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: '#EF4444', marginBottom: '8px' }}>Failed to Load Stations</h3>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: '#94A3B8', marginBottom: '16px' }}>{fetchError}</p>
|
||||||
|
<button onClick={fetchStations} style={{ padding: '10px 24px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '10px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
↺ Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && !fetchError && filteredStations.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
|
||||||
|
<MapPin size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#94A3B8', marginBottom: '8px' }}>No Stations Found</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem' }}>Click "Add New Station" to create your first dispatch station.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Station Cards */}
|
||||||
|
{!loading && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '20px' }}>
|
||||||
|
{filteredStations.map(station => (
|
||||||
|
<div key={station.id} style={{ ...cardStyle, position: 'relative', overflow: 'hidden', transition: 'transform 0.2s, box-shadow 0.2s', cursor: 'pointer' }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 32px rgba(6,182,212,0.1)'; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '4px', height: '100%', background: station.status === 'INACTIVE' ? '#EF4444' : '#10B981', borderRadius: '4px 0 0 4px' }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#fff' }}>{station.name}</h3>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Globe size={11} /> {station.id?.substring(0, 8)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: 'transparent', border: 'none', color: '#64748B', cursor: 'pointer', padding: '4px' }}><MoreVertical size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<MapPin size={15} color="#06B6D4" style={{ marginTop: '2px', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>{station.address}</div>
|
||||||
|
{(station.gps_lat || station.gps_lon) && (
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginTop: '2px' }}>
|
||||||
|
GPS: {station.gps_lat}, {station.gps_lon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{station.incharge_name && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<Users size={15} color="#06B6D4" />
|
||||||
|
<span style={{ fontSize: '0.85rem', color: '#94A3B8' }}>Incharge: <span style={{ fontWeight: 700, color: '#E2E8F0' }}>{station.incharge_name}</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{station.phone && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<Phone size={15} color="#06B6D4" />
|
||||||
|
<span style={{ fontSize: '0.85rem', color: '#94A3B8' }}>{station.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: 'rgba(255,255,255,0.06)', borderRadius: '10px', overflow: 'hidden', marginBottom: '16px' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '14px', background: '#040B16' }}>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#fff' }}>{station.vehiclesAssigned ?? '—'}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', marginTop: '4px' }}>Vehicles</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '14px', background: '#040B16' }}>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#fff' }}>{station.staffAssigned ?? '—'}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', marginTop: '4px' }}>Staff</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(station)}
|
||||||
|
disabled={loadingDetailsId !== null}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '9px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: loadingDetailsId === station.id ? '#06B6D4' : '#94A3B8',
|
||||||
|
fontSize: '0.75rem', fontWeight: 600,
|
||||||
|
cursor: loadingDetailsId !== null ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingDetailsId === station.id ? (
|
||||||
|
<Loader2 size={13} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Edit size={13} />
|
||||||
|
)}
|
||||||
|
{loadingDetailsId === station.id ? 'FETCHING...' : 'EDIT'}
|
||||||
|
</button>
|
||||||
|
<button style={{ flex: 1, padding: '9px', background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)', borderRadius: '8px', color: '#06B6D4', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||||
|
<Truck size={13} /> ASSIGN ASSETS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spin { animation: spin 1s linear infinite; }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,205 +1,433 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { UserPlus, Search, ShieldCheck, ChevronLeft, ChevronRight, Loader2, AlertCircle, Phone, Mail, X, Eye, EyeOff } from 'lucide-react';
|
||||||
UserPlus,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Users,
|
|
||||||
Medal,
|
|
||||||
Clock,
|
|
||||||
ShieldCheck,
|
|
||||||
AlertTriangle,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
MoreVertical
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
|
||||||
interface Staff {
|
interface Staff {
|
||||||
id: string;
|
id: string; type?: string; status?: string; createdAt?: string; aadhaar_number?: string;
|
||||||
name: string;
|
professional_details?: { qualification?: string; certificate_expiry?: string; certificate_number?: string; certification_body?: string; license_expiry?: string; license_number?: string; license_category?: string; };
|
||||||
role: 'DRIVER' | 'EMT' | 'DOCTOR' | 'PARAMEDIC';
|
user?: { name?: string; phone?: string; email?: string | null; status?: string; isAvailable?: boolean; };
|
||||||
status: 'ON_DUTY' | 'OFF_DUTY' | 'ON_LEAVE';
|
|
||||||
specialization?: string;
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
joinedDate: string;
|
|
||||||
tripsCompleted: number;
|
|
||||||
rating: number;
|
|
||||||
certExpiry: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_STAFF: Staff[] = [
|
const ROLE_COLORS: Record<string, { color: string; bg: string }> = {
|
||||||
{ id: 'S-101', name: 'Vikram Singh', role: 'DRIVER', status: 'ON_DUTY', phone: '+91 98765 43210', email: 'v.singh@teleems.com', joinedDate: '2023-01-15', tripsCompleted: 452, rating: 4.8, certExpiry: '2026-12-01' },
|
DRIVER: { color: '#06B6D4', bg: 'rgba(6,182,212,0.12)' },
|
||||||
{ id: 'S-102', name: 'Dr. Ananya Iyer', role: 'DOCTOR', specialization: 'Critical Care', status: 'ON_DUTY', phone: '+91 98765 43211', email: 'a.iyer@teleems.com', joinedDate: '2023-06-20', tripsCompleted: 128, rating: 4.9, certExpiry: '2026-05-15' },
|
EMT: { color: '#3B82F6', bg: 'rgba(59,130,246,0.12)' },
|
||||||
{ id: 'S-103', name: 'Rahul Verma', role: 'EMT', status: 'ON_LEAVE', phone: '+91 98765 43212', email: 'r.verma@teleems.com', joinedDate: '2024-02-10', tripsCompleted: 215, rating: 4.7, certExpiry: '2026-06-01' },
|
DOCTOR: { color: '#10B981', bg: 'rgba(16,185,129,0.12)' },
|
||||||
{ id: 'S-104', name: 'Suresh Kumar', role: 'DRIVER', status: 'OFF_DUTY', phone: '+91 98765 43213', email: 's.kumar@teleems.com', joinedDate: '2022-11-05', tripsCompleted: 890, rating: 4.6, certExpiry: '2026-08-20' },
|
};
|
||||||
];
|
const DEF = { color: '#94A3B8', bg: 'rgba(148,163,184,0.1)' };
|
||||||
|
const STATUS_CFG: Record<string, { label: string; color: string }> = {
|
||||||
|
ON_DUTY: { label: 'On Duty', color: '#10B981' }, OFF_DUTY: { label: 'Off Duty', color: '#64748B' },
|
||||||
|
ON_LEAVE: { label: 'On Leave', color: '#F59E0B' }, ACTIVE: { label: 'Active', color: '#10B981' }, INACTIVE: { label: 'Inactive', color: '#EF4444' },
|
||||||
|
};
|
||||||
|
const FILTERS = ['ALL', 'DRIVER', 'EMT', 'DOCTOR'] as const;
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
const th: React.CSSProperties = { padding: '14px 18px', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, textAlign: 'left', borderBottom: '1px solid rgba(255,255,255,0.06)', background: 'rgba(255,255,255,0.02)', whiteSpace: 'nowrap' };
|
||||||
|
|
||||||
|
const norm = (s: Staff) => {
|
||||||
|
const pd = s.professional_details;
|
||||||
|
return {
|
||||||
|
id: s.id, name: s.user?.name || '—', role: (s.type || '').toUpperCase(),
|
||||||
|
status: (s.status || s.user?.status || 'ACTIVE').toUpperCase(),
|
||||||
|
phone: s.user?.phone || '—', email: s.user?.email || null,
|
||||||
|
joinedDate: s.createdAt ? s.createdAt.substring(0, 10) : '',
|
||||||
|
specialization: pd?.qualification || pd?.license_category || pd?.certification_body || '',
|
||||||
|
certExpiry: pd?.certificate_expiry || pd?.license_expiry || '',
|
||||||
|
isAvailable: s.user?.isAvailable ?? true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const FleetPersonnel: React.FC = () => {
|
export const FleetPersonnel: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'ALL' | 'DRIVER' | 'EMT' | 'DOCTOR'>('ALL');
|
const [staffList, setStaffList] = useState<Staff[]>([]);
|
||||||
const [selectedStaff, setSelectedStaff] = useState<Staff | null>(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [filter, setFilter] = useState<typeof FILTERS[number]>('ALL');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedRaw, setSelectedRaw] = useState<Staff | null>(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitErr, setSubmitErr] = useState('');
|
||||||
|
const [submitOk, setSubmitOk] = useState('');
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
const [staffType, setStaffType] = useState<'DRIVER'|'EMT'|'DOCTOR'>('DRIVER');
|
||||||
|
const [form, setForm] = useState({ name:'', phone:'', password:'', aadhaar_number:'',
|
||||||
|
license_number:'', license_category:'', license_expiry:'',
|
||||||
|
qualification:'', certification_body:'', certificate_number:'', certificate_expiry:'',
|
||||||
|
medical_registration_number:'', specialization:'', teleconsult_available: false,
|
||||||
|
});
|
||||||
|
const setF = (k: string, v: string | boolean) => setForm(p => ({ ...p, [k]: v }));
|
||||||
|
const resetModal = () => { setForm({ name:'', phone:'', password:'', aadhaar_number:'', license_number:'', license_category:'', license_expiry:'', qualification:'', certification_body:'', certificate_number:'', certificate_expiry:'', medical_registration_number:'', specialization:'', teleconsult_available: false }); setStaffType('DRIVER'); setSubmitErr(''); setSubmitOk(''); };
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
const filteredStaff = MOCK_STAFF.filter(s => activeTab === 'ALL' || s.role === activeTab);
|
const fetchStaff = useCallback(async () => {
|
||||||
|
setLoading(true); setFetchError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } });
|
||||||
|
const json = await res.json();
|
||||||
|
if (res.status === 401 || res.status === 403) { setFetchError('Session expired.'); return; }
|
||||||
|
if (!res.ok) { setFetchError(json?.message || `Error ${res.status}`); return; }
|
||||||
|
let list: Staff[] = [];
|
||||||
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
setStaffList(list);
|
||||||
|
} catch (e) { setFetchError('Network error.'); } finally { setLoading(false); }
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
return (
|
const submitStaff = async () => {
|
||||||
<div className="fleet-personnel animate-in fade-in duration-500">
|
if (!form.name || !form.phone) { setSubmitErr('Name and phone are required.'); return; }
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
setSubmitting(true); setSubmitErr(''); setSubmitOk('');
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
let professional_details: Record<string, string | boolean> = {};
|
||||||
{['ALL', 'DRIVER', 'EMT', 'DOCTOR'].map(t => (
|
if (staffType === 'DRIVER') professional_details = { license_number: form.license_number, license_category: form.license_category, license_expiry: form.license_expiry };
|
||||||
<button
|
if (staffType === 'EMT') professional_details = { qualification: form.qualification, certification_body: form.certification_body, certificate_number: form.certificate_number, certificate_expiry: form.certificate_expiry };
|
||||||
key={t}
|
if (staffType === 'DOCTOR') professional_details = { medical_registration_number: form.medical_registration_number, specialization: form.specialization, qualification: form.qualification, teleconsult_available: form.teleconsult_available };
|
||||||
onClick={() => setActiveTab(t as any)}
|
const body = { name: form.name, phone: form.phone, password: form.password, type: staffType, aadhaar_number: form.aadhaar_number, professional_details };
|
||||||
style={{
|
try {
|
||||||
padding: '8px 16px',
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
borderRadius: '8px',
|
const json = await res.json();
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
if (!res.ok) { setSubmitErr(json?.message || `Error ${res.status}`); return; }
|
||||||
background: activeTab === t ? 'var(--accent-cyan)' : 'rgba(255,255,255,0.05)',
|
setSubmitOk(`${staffType} registered successfully!`);
|
||||||
color: activeTab === t ? '#000' : 'var(--text-secondary)',
|
resetModal();
|
||||||
fontSize: '0.75rem',
|
fetchStaff();
|
||||||
fontWeight: 700,
|
setTimeout(() => { setShowModal(false); setSubmitOk(''); }, 1500);
|
||||||
cursor: 'pointer',
|
} catch { setSubmitErr('Network error.'); } finally { setSubmitting(false); }
|
||||||
transition: 'all 0.2s'
|
};
|
||||||
}}
|
|
||||||
>
|
useEffect(() => { fetchStaff(); }, [fetchStaff]);
|
||||||
{t}S
|
|
||||||
</button>
|
const normalized = staffList.map(norm);
|
||||||
))}
|
const filtered = normalized.filter(s => (filter === 'ALL' || s.role === filter) && (s.name.toLowerCase().includes(search.toLowerCase()) || s.role.toLowerCase().includes(search.toLowerCase())));
|
||||||
</div>
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
const safePage = Math.min(page, totalPages);
|
||||||
<UserPlus size={18} /> REGISTER PERSONNEL
|
const pageData = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
</button>
|
const onDuty = normalized.filter(s => ['ON_DUTY', 'ACTIVE'].includes(s.status)).length;
|
||||||
|
const offDuty = normalized.filter(s => s.status === 'OFF_DUTY').length;
|
||||||
|
const onLeave = normalized.filter(s => s.status === 'ON_LEAVE').length;
|
||||||
|
|
||||||
|
// ── DETAIL PAGE ──
|
||||||
|
if (selectedRaw) {
|
||||||
|
const s = norm(selectedRaw);
|
||||||
|
const pd = selectedRaw.professional_details;
|
||||||
|
const rc = ROLE_COLORS[s.role] || DEF;
|
||||||
|
const sc = STATUS_CFG[s.status] || { label: s.status, color: '#94A3B8' };
|
||||||
|
const ini = s.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
|
const cw = s.certExpiry ? new Date(s.certExpiry) < new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) : false;
|
||||||
|
const row = (label: string, value: string, mono = false) => (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '12px' }}>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', color: '#E2E8F0', fontWeight: 600, fontFamily: mono ? 'monospace' : undefined, wordBreak: 'break-all' }}>{value || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} style={{ color: '#F8FAFC' }}>
|
||||||
|
<button onClick={() => setSelectedRaw(null)} style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '10px', padding: '9px 16px', color: '#94A3B8', cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600, marginBottom: '24px' }}>
|
||||||
|
<ChevronLeft size={16} /> Back to Staff List
|
||||||
|
</button>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
{/* Hero */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ background: `linear-gradient(135deg,${rc.color}18,rgba(255,255,255,0.02))`, border: `1px solid ${rc.color}30`, borderRadius: '20px', padding: '32px', marginBottom: '20px', position: 'relative', overflow: 'hidden' }}>
|
||||||
<Card style={{ padding: '0', overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', top: -40, right: -40, width: 180, height: 180, borderRadius: '50%', background: `${rc.color}10` }} />
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||||
<thead>
|
<div style={{ width: 80, height: 80, borderRadius: 22, background: rc.bg, border: `2px solid ${rc.color}60`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.6rem', fontWeight: 900, color: rc.color, boxShadow: `0 0 28px ${rc.color}30`, flexShrink: 0 }}>{ini}</div>
|
||||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)', textAlign: 'left', background: 'rgba(255,255,255,0.02)' }}>
|
<div>
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Personnel</th>
|
<h1 style={{ fontSize: '1.6rem', fontWeight: 900, color: '#fff', margin: '0 0 10px' }}>{s.name}</h1>
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Role / Specialization</th>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Status</th>
|
<span style={{ padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 800, background: rc.bg, color: rc.color, border: `1px solid ${rc.color}40` }}>{s.role}</span>
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Trips</th>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 700, background: `${sc.color}18`, color: sc.color }}>
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Actions</th>
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: sc.color, display: 'inline-block', boxShadow: `0 0 6px ${sc.color}` }} />{sc.label}
|
||||||
</tr>
|
</span>
|
||||||
</thead>
|
{s.isAvailable && <span style={{ padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 700, background: 'rgba(16,185,129,0.12)', color: '#10B981', border: '1px solid rgba(16,185,129,0.2)' }}>✓ Available</span>}
|
||||||
<tbody>
|
</div>
|
||||||
{filteredStaff.map(s => (
|
</div>
|
||||||
<tr
|
</div>
|
||||||
key={s.id}
|
|
||||||
onClick={() => setSelectedStaff(s)}
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: selectedStaff?.id === s.id ? 'rgba(59, 130, 246, 0.05)' : 'transparent'
|
|
||||||
}}
|
|
||||||
className="hover-glow"
|
|
||||||
>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-cyan)', fontWeight: 700, fontSize: '0.875rem', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
|
||||||
{s.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 700 }}>{s.name}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>ID: {s.id}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ fontSize: '0.8125rem', fontWeight: 600 }}>{s.role}</div>
|
|
||||||
{s.specialization && <div style={{ fontSize: '0.65rem', opacity: 0.5 }}>{s.specialization}</div>}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.status === 'ON_DUTY' ? '#22C55E' : s.status === 'ON_LEAVE' ? '#EF4444' : '#94A3B8' }}></div>
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, opacity: 0.8 }}>{s.status.replace('_', ' ')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ fontWeight: 800 }}>{s.tripsCompleted}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<button className="btn-ghost-sm"><MoreVertical size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: 'sticky', top: '0' }}>
|
{/* Grid */}
|
||||||
{selectedStaff ? (
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
{/* Contact */}
|
||||||
key={selectedStaff.id}
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 16, padding: 22 }}>
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>📞 Contact</div>
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10 }}>
|
||||||
>
|
<Phone size={16} color="#06B6D4" />
|
||||||
<Card>
|
<div><div style={{ fontSize: '0.62rem', color: '#64748B', marginBottom: 2 }}>Phone</div><div style={{ fontSize: '0.9rem', color: '#E2E8F0', fontWeight: 600 }}>{s.phone}</div></div>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
</div>
|
||||||
<div style={{ width: '80px', height: '80px', borderRadius: '50%', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-cyan)', fontSize: '1.5rem', fontWeight: 900, border: '2px solid var(--accent-cyan)', margin: '0 auto 16px', boxShadow: '0 0 20px rgba(59, 130, 246, 0.2)' }}>
|
{s.email && (
|
||||||
{selectedStaff.name.charAt(0)}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10 }}>
|
||||||
|
<Mail size={16} color="#06B6D4" />
|
||||||
|
<div><div style={{ fontSize: '0.62rem', color: '#64748B', marginBottom: 2 }}>Email</div><div style={{ fontSize: '0.88rem', color: '#E2E8F0', fontWeight: 600 }}>{s.email}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 16, padding: 22 }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>🪪 Identity</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{row('Staff ID', s.id, true)}
|
||||||
|
{selectedRaw.aadhaar_number && row('Aadhaar', selectedRaw.aadhaar_number, true)}
|
||||||
|
{row('Joined Date', s.joinedDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Professional — full width */}
|
||||||
|
{pd && (
|
||||||
|
<div style={{ gridColumn: '1 / -1', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 16, padding: 22 }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>🎓 Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(240px,1fr))', gap: 10 }}>
|
||||||
|
{pd.qualification && row('Qualification', pd.qualification)}
|
||||||
|
{pd.certification_body && row('Certification Body', pd.certification_body)}
|
||||||
|
{(pd.certificate_number || pd.license_number) && row('Cert / License No.', pd.certificate_number || pd.license_number || '', true)}
|
||||||
|
{pd.license_category && row('License Category', pd.license_category)}
|
||||||
|
{s.specialization && row('Specialization', s.specialization)}
|
||||||
|
{s.certExpiry && (
|
||||||
|
<div style={{ padding: '14px 16px', background: cw ? 'rgba(245,158,11,0.08)' : 'rgba(16,185,129,0.07)', border: `1px solid ${cw ? 'rgba(245,158,11,0.25)' : 'rgba(16,185,129,0.2)'}`, borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5, textTransform: 'uppercase' }}>
|
||||||
|
<ShieldCheck size={11} color={cw ? '#F59E0B' : '#10B981'} /> Cert Expiry
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 800 }}>{selectedStaff.name}</h2>
|
<div style={{ fontSize: '0.95rem', fontWeight: 800, color: cw ? '#F59E0B' : '#10B981' }}>{s.certExpiry}</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{selectedStaff.role}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
|
</div>
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>Trips Rate</div>
|
|
||||||
<div style={{ fontSize: '1.125rem', fontWeight: 800, color: 'var(--accent-green)' }}>{selectedStaff.rating}/5.0</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>SLA Compliance</div>
|
|
||||||
<div style={{ fontSize: '1.125rem', fontWeight: 800 }}>98.4%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase', opacity: 0.5, marginBottom: '8px' }}>Contact Information</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '0.8125rem' }}>
|
|
||||||
<Phone size={14} style={{ opacity: 0.5 }} /> {selectedStaff.phone}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '0.8125rem' }}>
|
|
||||||
<Mail size={14} style={{ opacity: 0.5 }} /> {selectedStaff.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase', opacity: 0.5, marginBottom: '8px' }}>Certifications</h4>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 700 }}>Professional License</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.6 }}>Expiry: {selectedStaff.certExpiry}</div>
|
|
||||||
</div>
|
|
||||||
<AlertTriangle size={16} color="#F59E0B" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn-primary" style={{ width: '100%', marginTop: '24px' }}>MANAGE SHIFT SCHEDULE</button>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
) : (
|
|
||||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
|
||||||
<Users size={32} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>Select Personnel</h3>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>View detailed performance metrics, licensing status, and shift history for your fleet crew.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}`}</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TABLE VIEW ──
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#F8FAFC' }}>
|
||||||
|
|
||||||
|
{/* ── Add Staff Modal ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, backdropFilter: 'blur(6px)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div initial={{ scale: 0.93, y: 20, opacity: 0 }} animate={{ scale: 1, y: 0, opacity: 1 }} exit={{ scale: 0.93, y: 20, opacity: 0 }}
|
||||||
|
style={{ background: '#0D1526', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 24, width: 780, maxWidth: '96vw', position: 'relative' }}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg,rgba(6,182,212,0.12),transparent)', borderBottom: '1px solid rgba(255,255,255,0.07)', padding: '20px 28px 16px', borderRadius: '24px 24px 0 0' }}>
|
||||||
|
<button onClick={() => setShowModal(false)} style={{ position: 'absolute', top: 14, right: 14, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, padding: 6, color: '#94A3B8', cursor: 'pointer', display: 'flex' }}><X size={15} /></button>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 900, color: '#fff' }}>Register New Staff</h3>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#64748B' }}>Fill details based on staff type</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Type Selector */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{(['DRIVER','EMT','DOCTOR'] as const).map(t => {
|
||||||
|
const rc = ROLE_COLORS[t];
|
||||||
|
return (
|
||||||
|
<button key={t} onClick={() => setStaffType(t)}
|
||||||
|
style={{ flex: 1, padding: '9px 0', borderRadius: 10, border: `2px solid ${staffType === t ? rc.color : 'rgba(255,255,255,0.08)'}`, background: staffType === t ? rc.bg : 'rgba(255,255,255,0.03)', color: staffType === t ? rc.color : '#64748B', fontWeight: 800, fontSize: '0.8rem', cursor: 'pointer', transition: 'all 0.2s' }}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Fields — 2 col grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k: 'name', label: 'Full Name', ph: 'e.g. Driver 2' }, { k: 'phone', label: 'Phone', ph: '9999999998' }, { k: 'aadhaar_number', label: 'Aadhaar Number', ph: '1234-5678-9001' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 5 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 9, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Password — same row as Aadhaar */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 5 }}>Password <span style={{ color: '#334155', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input type={showPw ? 'text' : 'password'} value={form.password} onChange={e => setF('password', e.target.value)} placeholder="Min 8 chars"
|
||||||
|
autoComplete="new-password"
|
||||||
|
style={{ width: '100%', padding: '9px 36px 9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 9, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
<button onClick={() => setShowPw(p => !p)} style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DRIVER fields */}
|
||||||
|
{staffType === 'DRIVER' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(6,182,212,0.05)', border: '1px solid rgba(6,182,212,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#06B6D4', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>Driver Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'license_number', label:'License Number', ph:'DL-KA-2024-DR1' }, { k:'license_category', label:'License Category', ph:'MCWG/LMV' }, { k:'license_expiry', label:'License Expiry', ph:'2034-01-01' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, color: '#fff', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EMT fields */}
|
||||||
|
{staffType === 'EMT' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(59,130,246,0.05)', border: '1px solid rgba(59,130,246,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#3B82F6', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>EMT Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'qualification', label:'Qualification', ph:'Diploma in EMT' }, { k:'certification_body', label:'Certification Body', ph:'Indian Resuscitation Council' }, { k:'certificate_number', label:'Certificate Number', ph:'EMT-CERT-9901' }, { k:'certificate_expiry', label:'Certificate Expiry', ph:'2029-08-15' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, color: '#fff', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DOCTOR fields */}
|
||||||
|
{staffType === 'DOCTOR' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(16,185,129,0.05)', border: '1px solid rgba(16,185,129,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#10B981', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>Doctor Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'medical_registration_number', label:'Medical Reg. Number', ph:'MCI/DMC/2024/77' }, { k:'specialization', label:'Specialization', ph:'Emergency Medicine' }, { k:'qualification', label:'Qualification', ph:'MBBS, MD (Emergency)' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, color: '#fff', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.82rem', color: '#94A3B8', gridColumn: '1 / -1' }}>
|
||||||
|
<input type="checkbox" checked={form.teleconsult_available} onChange={e => setF('teleconsult_available', e.target.checked)} style={{ accentColor: '#10B981', width: 15, height: 15 }} />
|
||||||
|
Teleconsult Available
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitErr && <div style={{ padding: '9px 14px', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 9, color: '#EF4444', fontSize: '0.8rem' }}>{submitErr}</div>}
|
||||||
|
{submitOk && <div style={{ padding: '9px 14px', background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', borderRadius: 9, color: '#10B981', fontSize: '0.8rem', fontWeight: 700 }}>✓ {submitOk}</div>}
|
||||||
|
|
||||||
|
<button onClick={submitStaff} disabled={submitting}
|
||||||
|
style={{ padding: '11px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 11, color: '#fff', fontWeight: 800, fontSize: '0.88rem', cursor: submitting ? 'not-allowed' : 'pointer', opacity: submitting ? 0.7 : 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{submitting ? <><Loader2 size={15} style={{ animation: 'spin 1s linear infinite' }} /> Registering...</> : <><UserPlus size={15} /> Register {staffType}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 14, marginBottom: 24 }}>
|
||||||
|
{[{ label: 'Total Staff', value: normalized.length, color: '#06B6D4' }, { label: 'On Duty', value: onDuty, color: '#10B981' }, { label: 'Off Duty', value: offDuty, color: '#64748B' }, { label: 'On Leave', value: onLeave, color: '#F59E0B' }].map(s => (
|
||||||
|
<div key={s.label} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 14, padding: '18px 20px', borderLeft: `4px solid ${s.color}` }}>
|
||||||
|
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#fff', lineHeight: 1 }}>{loading ? '—' : s.value}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, background: 'rgba(255,255,255,0.03)', padding: 4, borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
{FILTERS.map(f => (
|
||||||
|
<button key={f} onClick={() => { setFilter(f); setPage(1); }} style={{ padding: '7px 14px', borderRadius: 8, border: 'none', cursor: 'pointer', fontSize: '0.78rem', fontWeight: filter === f ? 700 : 500, background: filter === f ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'transparent', color: filter === f ? '#fff' : '#64748B', transition: 'all 0.2s' }}>
|
||||||
|
{f === 'ALL' ? 'All' : f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'rgba(255,255,255,0.03)', padding: '8px 14px', borderRadius: 10, border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<Search size={14} color="#64748B" />
|
||||||
|
<input value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} placeholder="Search staff..." style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.85rem', outline: 'none', width: 170 }} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { resetModal(); setShowModal(true); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 18px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 10, color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.82rem', whiteSpace: 'nowrap', boxShadow: '0 4px 12px rgba(6,182,212,0.3)' }}>
|
||||||
|
<UserPlus size={15} /> ADD STAFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200, gap: 12, color: '#64748B' }}><Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} /><span>Loading staff...</span></div>}
|
||||||
|
|
||||||
|
{!loading && fetchError && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, background: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: 16 }}>
|
||||||
|
<AlertCircle size={32} color="#EF4444" style={{ marginBottom: 12 }} />
|
||||||
|
<p style={{ color: '#EF4444', fontWeight: 600, marginBottom: 12 }}>{fetchError}</p>
|
||||||
|
<button onClick={fetchStaff} style={{ padding: '9px 22px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 8, color: '#fff', fontWeight: 700, cursor: 'pointer' }}>↺ Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && (
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 16, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['#', 'Staff Member', 'Role', 'Status', 'Phone', 'Specialization', 'Joined', 'Cert Expiry'].map(h => <th key={h} style={th}>{h}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageData.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 48, color: '#64748B' }}>{staffList.length === 0 ? 'No staff registered yet.' : 'No results match your filter.'}</td></tr>
|
||||||
|
) : pageData.map((s, idx) => {
|
||||||
|
const rc = ROLE_COLORS[s.role] || DEF;
|
||||||
|
const sc = STATUS_CFG[s.status] || { label: s.status, color: '#94A3B8' };
|
||||||
|
const certSoon = s.certExpiry ? new Date(s.certExpiry) < new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) : false;
|
||||||
|
return (
|
||||||
|
<tr key={s.id}
|
||||||
|
onClick={() => { const raw = staffList.find(r => r.id === s.id); if (raw) setSelectedRaw(raw); }}
|
||||||
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', cursor: 'pointer', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.04)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '14px 18px', color: '#475569', fontSize: '0.78rem', fontWeight: 600 }}>{(safePage - 1) * PAGE_SIZE + idx + 1}</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 38, height: 38, borderRadius: 10, background: rc.bg, border: `1.5px solid ${rc.color}40`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.85rem', fontWeight: 900, color: rc.color, flexShrink: 0 }}>
|
||||||
|
{s.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.88rem', color: '#F1F5F9' }}>{s.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ padding: '4px 10px', borderRadius: 6, fontSize: '0.68rem', fontWeight: 800, background: rc.bg, color: rc.color }}>{s.role || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: sc.color, boxShadow: `0 0 6px ${sc.color}` }} />
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: sc.color }}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.78rem', color: '#94A3B8' }}><Phone size={12} color="#06B6D4" />{s.phone}</div></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ fontSize: '0.78rem', color: '#94A3B8' }}>{s.specialization || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ fontSize: '0.78rem', color: '#94A3B8' }}>{s.joinedDate || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
{s.certExpiry ? <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><ShieldCheck size={14} color={certSoon ? '#F59E0B' : '#10B981'} /><span style={{ fontSize: '0.75rem', fontWeight: 600, color: certSoon ? '#F59E0B' : '#94A3B8' }}>{s.certExpiry}</span></div> : <span style={{ color: '#475569' }}>—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* Pagination */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 20px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: '#64748B' }}>Showing <strong style={{ color: '#94A3B8' }}>{filtered.length === 0 ? 0 : (safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)}</strong> of <strong style={{ color: '#94A3B8' }}>{filtered.length}</strong> staff</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={safePage === 1} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.04)', color: safePage === 1 ? '#334155' : '#94A3B8', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}><ChevronLeft size={15} /></button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||||
|
<button key={p} onClick={() => setPage(p)} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: p === safePage ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'rgba(255,255,255,0.04)', color: p === safePage ? '#fff' : '#64748B', fontWeight: p === safePage ? 700 : 500, cursor: 'pointer', fontSize: '0.82rem' }}>{p}</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.04)', color: safePage === totalPages ? '#334155' : '#94A3B8', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronRight size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,165 +1,556 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
|
||||||
Truck,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
|
||||||
Plus,
|
Plus,
|
||||||
ChevronLeft,
|
User,
|
||||||
ChevronRight,
|
Stethoscope,
|
||||||
MoreVertical,
|
Car,
|
||||||
Navigation,
|
X,
|
||||||
ShieldAlert
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Activity,
|
||||||
|
Calendar
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
|
||||||
|
// --- INTERFACES ---
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
vehicleReg?: string;
|
||||||
|
vehicleType: 'ALS' | 'BLS' | 'TRANS';
|
||||||
shift: 'MORNING' | 'EVENING' | 'NIGHT';
|
shift: 'MORNING' | 'EVENING' | 'NIGHT';
|
||||||
driver: string;
|
driver: string;
|
||||||
emt: string;
|
emt: string;
|
||||||
doctor?: string;
|
doctor?: string;
|
||||||
status: 'SCHEDULED' | 'ON_DUTY' | 'HANDOVER_PENDING';
|
status: 'SCHEDULED' | 'ON_DUTY';
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_ASSIGNMENTS: Assignment[] = [
|
interface APIVehicle {
|
||||||
{ id: 'AS-1001', vehicleId: 'V-001 (ALS)', shift: 'MORNING', driver: 'Vikram Singh', emt: 'Rahul Verma', doctor: 'Dr. Ananya Iyer', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
id: string;
|
||||||
{ id: 'AS-1002', vehicleId: 'V-002 (BLS)', shift: 'MORNING', driver: 'Suresh Kumar', emt: 'Amit Roy', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
registration_number: string;
|
||||||
{ id: 'AS-1003', vehicleId: 'V-003 (ALS)', shift: 'EVENING', driver: 'Karan Mehra', emt: 'Priya Das', doctor: 'Dr. Sameer Gupta', status: 'SCHEDULED', startTime: '14:00', endTime: '22:00' },
|
vehicle_type: string;
|
||||||
{ id: 'AS-1004', vehicleId: 'V-004 (TRANS)', shift: 'MORNING', driver: 'Ravi Teja', emt: 'Sneha Rao', status: 'HANDOVER_PENDING', startTime: '06:00', endTime: '14:00' },
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APIStaff {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
user?: {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIFTS = [
|
||||||
|
{ key: 'MORNING', label: 'Morning', time: '06:00 – 14:00', color: '#F59E0B', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
{ key: 'EVENING', label: 'Evening', time: '14:00 – 22:00', color: '#3B82F6', bg: 'rgba(59,130,246,0.1)' },
|
||||||
|
{ key: 'NIGHT', label: 'Night', time: '22:00 – 06:00', color: '#8B5CF6', bg: 'rgba(139,92,246,0.1)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Fallback collections if live database list is initially empty
|
||||||
|
const FALLBACK_STAFF = {
|
||||||
|
DRIVERS: [
|
||||||
|
{ id: '48360abc-ec1c-4cd3-a8e6-fed791249d8b', name: 'Vikram Singh' },
|
||||||
|
{ id: 'e971d2b1-9ee8-4c4c-897d-6fd81f8f307a', name: 'Suresh Kumar' },
|
||||||
|
{ id: '9cfd91fb-db92-491b-8f35-dcba1f2ea8b2', name: 'Karan Mehra' }
|
||||||
|
],
|
||||||
|
EMTS: [
|
||||||
|
{ id: '1a6b6c77-26a4-4776-8681-79f9d961050c', name: 'Rahul Verma' },
|
||||||
|
{ id: '7d04e5d8-df6c-47ea-bc64-1da0f3da97e2', name: 'Amit Roy' },
|
||||||
|
{ id: '24b42b10-09a7-47b2-a42d-7bbbb3ef3d4c', name: 'Priya Das' }
|
||||||
|
],
|
||||||
|
DOCTORS: [
|
||||||
|
{ id: '893fbf2a-cb0c-4e89-a292-0b2a60714b60', name: 'Dr. Ananya Iyer' },
|
||||||
|
{ id: '57c3e387-a3a8-44fb-9389-702b85e05445', name: 'Dr. Sameer Gupta' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_VEHICLES = [
|
||||||
|
{ id: '20c5f964-aa6c-4df0-a656-06d5b9893607', registration_number: 'TN-06-AM-1001', vehicle_type: 'ALS' },
|
||||||
|
{ id: '46c4f347-695c-4869-9da2-0c9f8ef4422c', registration_number: 'TN-06-AM-1002', vehicle_type: 'BLS' },
|
||||||
|
{ id: 'd9cfa42f-77fa-4a2b-bc6b-31a89bf16ab8', registration_number: 'TN-06-AM-1003', vehicle_type: 'ALS' },
|
||||||
|
{ id: '8c4e4776-8bf0-427c-bc70-4f812328ba8a', registration_number: 'TN-06-AM-1004', vehicle_type: 'TRANS' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FleetScheduling: React.FC = () => {
|
export const FleetScheduling: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
// --- STATE ---
|
||||||
|
const [selectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||||
|
const [view, setView] = useState<'DAY' | 'WEEK' | 'MONTH'>('DAY');
|
||||||
|
|
||||||
|
// Roster lists & API state
|
||||||
|
const [apiVehicles, setApiVehicles] = useState<APIVehicle[]>([]);
|
||||||
|
const [apiStaff, setApiStaff] = useState<APIStaff[]>([]);
|
||||||
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
|
const [submittingRoster, setSubmittingRoster] = useState(false);
|
||||||
|
const [rosterSuccess, setRosterSuccess] = useState('');
|
||||||
|
const [rosterErr, setRosterErr] = useState('');
|
||||||
|
|
||||||
|
const [assignments, setAssignments] = useState<Assignment[]>([
|
||||||
|
{ id: 'AS-1001', date: new Date().toISOString().split('T')[0], vehicleId: '20c5f964-aa6c-4df0-a656-06d5b9893607', vehicleReg: 'TN-06-AM-1001', vehicleType: 'ALS', shift: 'MORNING', driver: 'Vikram Singh', emt: 'Rahul Verma', doctor: 'Dr. Ananya Iyer', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
||||||
|
{ id: 'AS-1002', date: new Date().toISOString().split('T')[0], vehicleId: '46c4f347-695c-4869-9da2-0c9f8ef4422c', vehicleReg: 'TN-06-AM-1002', vehicleType: 'BLS', shift: 'MORNING', driver: 'Suresh Kumar', emt: 'Amit Roy', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
||||||
|
{ id: 'AS-1003', date: new Date().toISOString().split('T')[0], vehicleId: 'd9cfa42f-77fa-4a2b-bc6b-31a89bf16ab8', vehicleReg: 'TN-06-AM-1003', vehicleType: 'ALS', shift: 'EVENING', driver: 'Karan Mehra', emt: 'Priya Das', doctor: 'Dr. Sameer Gupta', status: 'SCHEDULED', startTime: '14:00', endTime: '22:00' },
|
||||||
|
{ id: 'AS-1004', date: new Date().toISOString().split('T')[0], vehicleId: '8c4e4776-8bf0-427c-bc70-4f812328ba8a', vehicleReg: 'TN-06-AM-1004', vehicleType: 'TRANS', shift: 'MORNING', driver: 'Ravi Teja', emt: 'Sneha Rao', status: 'SCHEDULED', startTime: '06:00', endTime: '14:00' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Modals state
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
// Form states for creating dynamic Roster
|
||||||
|
const [formVehicleId, setFormVehicleId] = useState('');
|
||||||
|
const [formDriverId, setFormDriverId] = useState('');
|
||||||
|
const [formStaffId, setFormStaffId] = useState('');
|
||||||
|
const [formStartDate, setFormStartDate] = useState('');
|
||||||
|
const [formEndDate, setFormEndDate] = useState('');
|
||||||
|
const [formShiftType, setFormShiftType] = useState<'DAY' | 'NIGHT' | 'SPLIT' | ''>('');
|
||||||
|
const [formNotes, setFormNotes] = useState('');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
// --- FETCH OPERATOR ASSETS (Vehicles & Staff) ---
|
||||||
|
const fetchAssets = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoadingAssets(true);
|
||||||
|
try {
|
||||||
|
const vRes = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const vJson = await vRes.json();
|
||||||
|
let vList: APIVehicle[] = [];
|
||||||
|
if (vJson?.data?.data && Array.isArray(vJson.data.data)) vList = vJson.data.data;
|
||||||
|
else if (vJson?.data && Array.isArray(vJson.data)) vList = vJson.data;
|
||||||
|
else if (Array.isArray(vJson)) vList = vJson;
|
||||||
|
setApiVehicles(vList);
|
||||||
|
|
||||||
|
const sRes = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const sJson = await sRes.json();
|
||||||
|
let sList: APIStaff[] = [];
|
||||||
|
if (sJson?.data?.data && Array.isArray(sJson.data.data)) sList = sJson.data.data;
|
||||||
|
else if (sJson?.data && Array.isArray(sJson.data)) sList = sJson.data;
|
||||||
|
else if (Array.isArray(sJson)) sList = sJson;
|
||||||
|
setApiStaff(sList);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Scheduling API Check] Failed to sync assets:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingAssets(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAssets();
|
||||||
|
}, [fetchAssets]);
|
||||||
|
|
||||||
|
const driverOptions = useMemo(() => {
|
||||||
|
const list = apiStaff.filter(s => s.type === 'DRIVER').map(s => ({ id: s.id, name: s.user?.name || 'Unknown Pilot' }));
|
||||||
|
return list.length > 0 ? list : FALLBACK_STAFF.DRIVERS;
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const emtOptions = useMemo(() => {
|
||||||
|
const list = apiStaff.filter(s => s.type === 'EMT').map(s => ({ id: s.id, name: s.user?.name || 'Unknown EMT' }));
|
||||||
|
return list.length > 0 ? list : FALLBACK_STAFF.EMTS;
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const doctorOptions = useMemo(() => {
|
||||||
|
const list = apiStaff.filter(s => s.type === 'DOCTOR').map(s => ({ id: s.id, name: s.user?.name || 'Unknown Doctor' }));
|
||||||
|
return list.length > 0 ? list : FALLBACK_STAFF.DOCTORS;
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const vehicleOptions = useMemo(() => {
|
||||||
|
const list = apiVehicles.map(v => ({ id: v.id, registration_number: v.registration_number, vehicle_type: v.vehicle_type || 'ALS' }));
|
||||||
|
return list.length > 0 ? list : FALLBACK_VEHICLES;
|
||||||
|
}, [apiVehicles]);
|
||||||
|
|
||||||
|
const currentFilteredAssignments = useMemo(() => {
|
||||||
|
return assignments.filter(as => {
|
||||||
|
if (view === 'DAY') {
|
||||||
|
return as.date === selectedDate;
|
||||||
|
} else if (view === 'WEEK') {
|
||||||
|
const dateDiff = Math.abs(new Date(as.date).getTime() - new Date(selectedDate).getTime());
|
||||||
|
const daysDiff = dateDiff / (1000 * 3600 * 24);
|
||||||
|
return daysDiff <= 3;
|
||||||
|
} else {
|
||||||
|
return as.date.substring(0, 7) === selectedDate.substring(0, 7);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [assignments, selectedDate, view]);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setFormVehicleId('');
|
||||||
|
setFormDriverId('');
|
||||||
|
setFormStaffId('');
|
||||||
|
setFormStartDate('');
|
||||||
|
setFormEndDate('');
|
||||||
|
setFormShiftType('');
|
||||||
|
setFormNotes('');
|
||||||
|
setRosterErr('');
|
||||||
|
setRosterSuccess('');
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAssignment = async () => {
|
||||||
|
setRosterErr('');
|
||||||
|
setRosterSuccess('');
|
||||||
|
|
||||||
|
if (!formVehicleId) {
|
||||||
|
setRosterErr('Please select an Ambulance Unit.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formShiftType) {
|
||||||
|
setRosterErr('Please select a Shift Type.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formDriverId) {
|
||||||
|
setRosterErr('Please select a Driver.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formStaffId) {
|
||||||
|
setRosterErr('Please select a Staff Member (EMT/Doctor).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formStartDate || !formEndDate) {
|
||||||
|
setRosterErr('Please select both Start Date and End Date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUuid = (str: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||||||
|
|
||||||
|
if (!isUuid(formVehicleId) || !isUuid(formDriverId) || !isUuid(formStaffId)) {
|
||||||
|
setRosterErr('Selected items must be valid backend UUIDs. Please register assets first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
vehicleId: formVehicleId,
|
||||||
|
driverId: formDriverId,
|
||||||
|
staffId: formStaffId,
|
||||||
|
startDate: formStartDate,
|
||||||
|
endDate: formEndDate,
|
||||||
|
shiftType: formShiftType,
|
||||||
|
notes: formNotes || 'Fleet Deployment Roster'
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmittingRoster(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/roster', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(json?.message || `API Response Error ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRosterSuccess('Roster scheduled successfully on backend API!');
|
||||||
|
|
||||||
|
const selectedV = vehicleOptions.find(v => v.id === formVehicleId);
|
||||||
|
const selectedDriver = driverOptions.find(d => d.id === formDriverId);
|
||||||
|
const selectedStaff = emtOptions.find(e => e.id === formStaffId) || doctorOptions.find(doc => doc.id === formStaffId);
|
||||||
|
|
||||||
|
const newAs: Assignment = {
|
||||||
|
id: json?.data?.id || `AS-${1000 + assignments.length + 1}`,
|
||||||
|
date: formStartDate,
|
||||||
|
vehicleId: formVehicleId,
|
||||||
|
vehicleReg: selectedV?.registration_number || formVehicleId,
|
||||||
|
vehicleType: (selectedV?.vehicle_type as any) || 'ALS',
|
||||||
|
shift: formShiftType === 'NIGHT' ? 'NIGHT' : (formShiftType === 'DAY' ? 'MORNING' : 'EVENING'),
|
||||||
|
driver: selectedDriver?.name || 'Driver',
|
||||||
|
emt: selectedStaff?.name || 'EMT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
startTime: formShiftType === 'NIGHT' ? '22:00' : (formShiftType === 'DAY' ? '06:00' : '14:00'),
|
||||||
|
endTime: formShiftType === 'NIGHT' ? '06:00' : (formShiftType === 'DAY' ? '14:00' : '22:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
setAssignments(prev => [newAs, ...prev]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setRosterSuccess('');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Create Roster Error]:', err);
|
||||||
|
setRosterSuccess('Roster simulated locally (Backend API offline or demo credentials)');
|
||||||
|
|
||||||
|
const selectedV = vehicleOptions.find(v => v.id === formVehicleId);
|
||||||
|
const selectedDriver = driverOptions.find(d => d.id === formDriverId);
|
||||||
|
const selectedStaff = emtOptions.find(e => e.id === formStaffId) || doctorOptions.find(doc => doc.id === formStaffId);
|
||||||
|
|
||||||
|
const newAs: Assignment = {
|
||||||
|
id: `AS-${1000 + assignments.length + 1}`,
|
||||||
|
date: formStartDate,
|
||||||
|
vehicleId: formVehicleId,
|
||||||
|
vehicleReg: selectedV?.registration_number || formVehicleId,
|
||||||
|
vehicleType: (selectedV?.vehicle_type as any) || 'ALS',
|
||||||
|
shift: formShiftType === 'NIGHT' ? 'NIGHT' : (formShiftType === 'DAY' ? 'MORNING' : 'EVENING'),
|
||||||
|
driver: selectedDriver?.name || 'Driver',
|
||||||
|
emt: selectedStaff?.name || 'EMT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
startTime: formShiftType === 'NIGHT' ? '22:00' : (formShiftType === 'DAY' ? '06:00' : '14:00'),
|
||||||
|
endTime: formShiftType === 'NIGHT' ? '06:00' : (formShiftType === 'DAY' ? '14:00' : '22:00')
|
||||||
|
};
|
||||||
|
setAssignments(prev => [newAs, ...prev]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setRosterSuccess('');
|
||||||
|
}, 1800);
|
||||||
|
} finally {
|
||||||
|
setSubmittingRoster(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-scheduling animate-in fade-in duration-500">
|
<div style={{ color: '#F8FAFC', fontFamily: 'Inter, sans-serif' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
{/* ── Header Toolbar (View selection controls & create button) ── */}
|
||||||
<div className="glass" style={{ padding: '8px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronLeft size={16} /></button>
|
|
||||||
<span style={{ fontWeight: 800, fontSize: '0.875rem' }}>{selectedDate}</span>
|
{/* View Mode Toggle (Daily, Weekly, Monthly) */}
|
||||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronRight size={16} /></button>
|
<div style={{ display: 'flex', gap: 4, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10, padding: 3 }}>
|
||||||
</div>
|
{(['DAY', 'WEEK', 'MONTH'] as const).map(v => (
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<button key={v} onClick={() => setView(v)}
|
||||||
{['DAY', 'WEEK', 'MONTH'].map(v => (
|
style={{ padding: '8px 16px', borderRadius: 8, border: 'none', fontSize: '0.78rem', fontWeight: 700, cursor: 'pointer', background: view === v ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'transparent', color: view === v ? '#fff' : '#64748B', transition: 'all 0.2s' }}>
|
||||||
<button key={v} style={{ fontSize: '0.65rem', fontWeight: 900, padding: '6px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.05)', background: v === 'DAY' ? 'var(--accent-cyan)' : 'transparent', color: v === 'DAY' ? '#000' : 'var(--text-secondary)' }}>{v}</button>
|
{v === 'DAY' ? 'Daily' : v === 'WEEK' ? 'Weekly' : 'Monthly'}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Plus size={18} /> CREATE NEW ASSIGNMENT
|
{/* Action Buttons */}
|
||||||
</button>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={fetchAssets} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 12px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#94A3B8', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}>
|
||||||
|
{loadingAssets ? <Loader2 size={13} className="spin" /> : '↺ Sync Assets'}
|
||||||
|
</button>
|
||||||
|
<button onClick={openCreateModal} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 20px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 12, color: '#fff', fontWeight: 800, fontSize: '0.82rem', cursor: 'pointer', boxShadow: '0 4px 14px rgba(6,182,212,0.3)', whiteSpace: 'nowrap' }}>
|
||||||
|
<Plus size={16} /> NEW CREW ASSIGNMENT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
{/* ── Roster Grid ── */}
|
||||||
{/* Mission Roster Grid */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<Card title="Shift Roster Matrix">
|
{/* Count summary label */}
|
||||||
<div className="table-container">
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
<thead>
|
Showing {currentFilteredAssignments.length} scheduled slots ({view === 'DAY' ? 'Today' : view === 'WEEK' ? 'Weekly Outlook' : 'Monthly'})
|
||||||
<tr style={{ textAlign: 'left', opacity: 0.5, fontSize: '0.65rem', textTransform: 'uppercase', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
</span>
|
||||||
<th style={{ padding: '12px' }}>Time Slot</th>
|
|
||||||
<th style={{ padding: '12px' }}>Vehicle</th>
|
|
||||||
<th style={{ padding: '12px' }}>Assigned Crew</th>
|
|
||||||
<th style={{ padding: '12px' }}>Status</th>
|
|
||||||
<th style={{ padding: '12px' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{MOCK_ASSIGNMENTS.map(as => (
|
|
||||||
<tr key={as.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 800, fontSize: '0.875rem', color: 'var(--accent-cyan)' }}>{as.startTime} - {as.endTime}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>{as.shift} SHIFT</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Truck size={14} style={{ opacity: 0.5 }} />
|
|
||||||
<span style={{ fontWeight: 700 }}>{as.vehicleId}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>P: {as.driver}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.8 }}>E: {as.emt}</div>
|
|
||||||
{as.doctor && <div style={{ fontSize: '0.75rem', color: 'var(--accent-green)' }}>D: {as.doctor}</div>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: as.status === 'ON_DUTY' ? 'rgba(34, 197, 94, 0.1)' : as.status === 'HANDOVER_PENDING' ? 'rgba(245, 158, 11, 0.1)' : 'rgba(148, 163, 184, 0.1)',
|
|
||||||
color: as.status === 'ON_DUTY' ? '#22C55E' : as.status === 'HANDOVER_PENDING' ? '#F59E0B' : '#94A3B8',
|
|
||||||
border: `1px solid ${as.status === 'ON_DUTY' ? 'rgba(34, 197, 94, 0.2)' : as.status === 'HANDOVER_PENDING' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(148, 163, 184, 0.2)'}`
|
|
||||||
}}>{as.status.replace('_', ' ')}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<button className="btn-ghost-sm"><MoreVertical size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conflict & Handover Panel */}
|
{/* Assignments Roster List */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<Card title="Conflict Engine" glowColor="amber">
|
{currentFilteredAssignments.length === 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ textAlign: 'center', padding: '64px 24px', background: 'rgba(255,255,255,0.01)', border: '1px dashed rgba(255,255,255,0.08)', borderRadius: 16 }}>
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.1)', display: 'flex', gap: '12px' }}>
|
<Calendar size={36} color="#64748B" style={{ marginBottom: 12 }} />
|
||||||
<ShieldAlert size={20} color="#F59E0B" />
|
<p style={{ color: '#64748B', fontSize: '0.88rem', margin: 0 }}>No crew roster scheduled for the selected view criteria.</p>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>DOUBLE BOOKING DETECTED</div>
|
) : (
|
||||||
<p style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: '4px' }}>Amit Roy (EMT) assigned to V-002 and V-005 in Evening Shift.</p>
|
currentFilteredAssignments.map((a, i) => {
|
||||||
</div>
|
const sh = SHIFTS.find(s => s.key === a.shift) || SHIFTS[0];
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(239, 68, 68, 0.05)', border: '1px solid rgba(239, 68, 68, 0.1)', display: 'flex', gap: '12px' }}>
|
|
||||||
<AlertTriangle size={20} color="#EF4444" />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>CERTIFICATION EXPIRED</div>
|
|
||||||
<p style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: '4px' }}>Dr. Sameer Gupta license expired on 2026-05-01.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Shift Handover Tracker">
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<motion.div key={a.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }}
|
||||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
style={{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
background: 'rgba(255,255,255,0.02)',
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>V-004 Handover Checklist</span>
|
border: '1px solid rgba(255,255,255,0.07)',
|
||||||
<span style={{ fontSize: '0.65rem', color: '#F59E0B', fontWeight: 800 }}>4/6 TASKS</span>
|
borderRadius: 16,
|
||||||
</div>
|
padding: '18px 24px',
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
}}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
>
|
||||||
<CheckCircle2 size={12} color="#22C55E" /> Fuel Tank Checked (100%)
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
{/* Vehicle identifier */}
|
||||||
|
<div style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 9, display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<Car size={14} color="#06B6D4" />
|
||||||
|
<span style={{ fontWeight: 800, fontSize: '0.82rem', color: '#fff' }}>{a.vehicleReg || a.vehicleId}</span>
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#06B6D4', background: 'rgba(6,182,212,0.12)', padding: '1px 7px', borderRadius: 4 }}>{a.vehicleType}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Badge */}
|
||||||
|
<div style={{ padding: '5px 10px', background: sh.bg, border: `1px solid ${sh.color}30`, borderRadius: 7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Clock size={11} color={sh.color} />
|
||||||
|
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: sh.color }}>{sh.label} Shift</span>
|
||||||
|
<span style={{ fontSize: '0.65rem', color: sh.color, opacity: 0.8 }}>{a.startTime}–{a.endTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date details */}
|
||||||
|
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#64748B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Calendar size={12} /> {a.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
|
||||||
<CheckCircle2 size={12} color="#22C55E" /> Oxygen Level Verified
|
{/* Driver + EMT (+ doctor if ALS) mapping */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
||||||
|
<User size={15} color="#06B6D4" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Pilot Driver</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>{a.driver}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(59,130,246,0.04)', border: '1px solid rgba(59,130,246,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
||||||
|
<Activity size={15} color="#3B82F6" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Emergency Medical Technician (EMT)</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>{a.emt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.vehicleType === 'ALS' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(16,185,129,0.04)', border: '1px solid rgba(16,185,129,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
||||||
|
<Stethoscope size={15} color="#10B981" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Advanced Medical Doctor</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>{a.doctor || 'Dr. Alok Mehta'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.5 }}>
|
</motion.div>
|
||||||
<Clock size={12} /> Narcotics Inventory Counter-sign
|
);
|
||||||
</div>
|
})
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CREATE ROSTER ASSIGNMENT MODAL ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCreateModal && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, backdropFilter: 'blur(6px)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget && !submittingRoster) setShowCreateModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div initial={{ scale: 0.93, y: 20, opacity: 0 }} animate={{ scale: 1, y: 0, opacity: 1 }} exit={{ scale: 0.93, y: 20, opacity: 0 }}
|
||||||
|
style={{ background: '#0D1526', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 24, width: 620, maxWidth: '95vw', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div style={{ background: 'linear-gradient(135deg,rgba(6,182,212,0.12),transparent)', borderBottom: '1px solid rgba(255,255,255,0.07)', padding: '20px 28px 16px', borderRadius: '24px 24px 0 0' }}>
|
||||||
|
<button onClick={() => setShowCreateModal(false)} disabled={submittingRoster} style={{ position: 'absolute', top: 14, right: 14, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, padding: 6, color: '#94A3B8', cursor: 'pointer', display: 'flex' }}><X size={15} /></button>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 900, color: '#fff' }}>Deploy & Roster Crew</h3>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#64748B' }}>Assign Driver + EMT (+ Doctor if ALS) to a vehicle for a shift</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button className="btn-primary" style={{ width: '100%', marginTop: '16px', fontSize: '0.75rem' }}>RESOLVE HANDOVERS</button>
|
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
</Card>
|
|
||||||
</div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
</div>
|
{/* Vehicle select */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Ambulance Unit</label>
|
||||||
|
<select value={formVehicleId} onChange={e => setFormVehicleId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>Select Ambulance</option>
|
||||||
|
{vehicleOptions.map(v => (
|
||||||
|
<option key={v.id} value={v.id} style={{ background: '#0D1526' }}>{v.registration_number} ({v.vehicle_type})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Selection */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Shift Type</label>
|
||||||
|
<select value={formShiftType} onChange={e => setFormShiftType(e.target.value as any)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>-- Select Shift Type --</option>
|
||||||
|
<option value="DAY" style={{ background: '#0D1526' }}>DAY (Morning/Evening)</option>
|
||||||
|
<option value="NIGHT" style={{ background: '#0D1526' }}>NIGHT (Night Shift)</option>
|
||||||
|
<option value="SPLIT" style={{ background: '#0D1526' }}>SPLIT (On-Demand)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{/* Driver deploy */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Assign Driver</label>
|
||||||
|
<select value={formDriverId} onChange={e => setFormDriverId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>-- Select Driver --</option>
|
||||||
|
{driverOptions.map(d => (
|
||||||
|
<option key={d.id} value={d.id} style={{ background: '#0D1526' }}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* EMT/Staff deploy */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Assign Staff (EMT/Doctor)</label>
|
||||||
|
<select value={formStaffId} onChange={e => setFormStaffId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>-- Select Staff Member --</option>
|
||||||
|
<optgroup label="Emergency Medical Techs" style={{ background: '#0D1526' }}>
|
||||||
|
{emtOptions.map(emt => (
|
||||||
|
<option key={emt.id} value={emt.id}>{emt.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="MD Physicians" style={{ background: '#0D1526' }}>
|
||||||
|
{doctorOptions.map(doc => (
|
||||||
|
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{/* Start Date */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Start Date</label>
|
||||||
|
<input type="date" value={formStartDate} onChange={e => setFormStartDate(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>End Date</label>
|
||||||
|
<input type="date" value={formEndDate} onChange={e => setFormEndDate(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Roster Notes</label>
|
||||||
|
<input type="text" value={formNotes} onChange={e => setFormNotes(e.target.value)} placeholder="e.g. Active dispatch on V-001 ALS"
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rosterErr && (
|
||||||
|
<div style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', padding: '10px 12px', borderRadius: 10, color: '#EF4444', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<AlertCircle size={14} /> {rosterErr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rosterSuccess && (
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', padding: '10px 12px', borderRadius: 10, color: '#10B981', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Check size={14} /> {rosterSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={handleCreateAssignment} disabled={submittingRoster}
|
||||||
|
style={{ width: '100%', padding: '12px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 12, color: '#fff', fontWeight: 800, fontSize: '0.88rem', cursor: submittingRoster ? 'not-allowed' : 'pointer', marginTop: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{submittingRoster ? <Loader2 size={16} className="spin" /> : 'FINALIZE ROSTER'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user