From 64a41be96b8dbfd90bd03c1255b2b55eab107cb1 Mon Sep 17 00:00:00 2001 From: KavyaCSH Date: Wed, 6 May 2026 17:09:54 +0530 Subject: [PATCH] first commit. --- src/App.tsx | 17 +- src/api/fleet.ts | 8 + src/components/PerspectiveSwitcher.tsx | 4 +- src/components/Sidebar.tsx | 415 +++++++++----- src/config/navigation.ts | 25 +- src/main.tsx | 5 +- src/pages/FleetLogin.tsx | 558 +++++++++++------- src/pages/FleetOperatorDashboard.tsx | 467 ++++----------- src/pages/fleet/FleetAssets.tsx | 752 +++++++++++++++++++------ src/pages/fleet/FleetOrganization.tsx | 642 +++++++++++++++++++++ src/pages/fleet/FleetPersonnel.tsx | 594 +++++++++++++------ src/pages/fleet/FleetScheduling.tsx | 653 ++++++++++++++++----- 12 files changed, 2921 insertions(+), 1219 deletions(-) create mode 100644 src/pages/fleet/FleetOrganization.tsx diff --git a/src/App.tsx b/src/App.tsx index 3a3436d..7a52d9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,9 @@ import { 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 --- const RoleProtectedRoute: React.FC<{ children: React.ReactNode, @@ -44,11 +47,13 @@ const RoleProtectedRoute: React.FC<{ if (!isAuthenticated) return ; 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) { - // Redirect to their respective "home" if they don't have access - if (userRoles.includes('FLEET_OPERATOR')) return ; + if (userRoles.some((r: string) => normaliseRole(r) === 'fleet_operator')) + return ; return ; } @@ -111,6 +116,7 @@ function AppContent() { */ 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 user = JSON.parse(localStorage.getItem('teleems_user') || '{}'); @@ -141,13 +147,14 @@ function AppContent() {
- + {!isFleetPage && }
normaliseRole(r) === 'fleet_operator') && + !user?.roles?.some((r: string) => normaliseRole(r) === 'cureselect_admin') ? : ) : ( diff --git a/src/api/fleet.ts b/src/api/fleet.ts index 5b1f68d..f00ad75 100644 --- a/src/api/fleet.ts +++ b/src/api/fleet.ts @@ -5,6 +5,14 @@ export const fleetApi = { 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) => { const url = organisationId ? `/v1/fleet/stations?organisationId=${organisationId}` diff --git a/src/components/PerspectiveSwitcher.tsx b/src/components/PerspectiveSwitcher.tsx index aa060ad..a1bbbaa 100644 --- a/src/components/PerspectiveSwitcher.tsx +++ b/src/components/PerspectiveSwitcher.tsx @@ -46,7 +46,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(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; useEffect(() => { @@ -158,7 +158,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol
{items.map((p) => { const ItemIcon = p.icon; - const isActive = p.id === currentRole; + const isActive = p.id.toLowerCase() === currentRole.toLowerCase(); return ( + + {/* Minimize/Collapse button near CureSelect title */} + {!isSidebarCollapsed && ( + + )}
- - + + {/* Navigation Area */} + + + {/* User Footer Profile */} +
+ + + {!isSidebarCollapsed && ( + + {/* Perspective Switcher */} + {(user.roles?.includes('cureselect_admin') || user.roles?.includes('admin') || user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && ( +
+ +
+ )} + + {/* Premium User Card */} +
+
+
{initials}
+
+
{displayName}
+
{currentRole.replace(/_/g, ' ')}
+
+
+ + +
+
+ )} +
+
+ + ); }; diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 4254ed7..5f957a0 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -56,21 +56,16 @@ export const NAVIGATION_CONFIG: NavItem[] = [ path: '/caller', roles: ['CURESELECT_ADMIN', 'CALLER'] }, - { - id: 'fleet-operator', - label: 'Fleet Command', - icon: Zap, - path: '/fleet-operator', - roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'], - subItems: [ - { id: 'fleet-overview', label: 'Command Center', icon: LayoutGrid, path: '/fleet-operator?tab=overview', 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-personnel', label: 'Personnel Hub', icon: Users, path: '/fleet-operator?tab=personnel', 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: 'fleet-overview', label: 'Live Dashboard', icon: LayoutGrid, path: '/fleet-operator?tab=overview', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, + { id: 'fleet-organization', label: 'Org & Stations', icon: Hospital, path: '/fleet-operator?tab=organization', 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-personnel', label: 'Staf Management', icon: Users, path: '/fleet-operator?tab=personnel', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, + { id: 'fleet-mission', label: 'Crew Scheduling', icon: Navigation, path: '/fleet-operator?tab=scheduling', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, + { id: 'fleet-inventory', label: 'Inventory Management', icon: ShoppingCart, path: '/fleet-operator?tab=inventory', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, + { id: 'fleet-trips', label: 'Trip Management', icon: Activity, path: '/fleet-operator?tab=trips', 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-referrals', label: 'Referral Network', icon: HeartPulse, path: '/fleet-operator?tab=referrals', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, + { id: 'fleet-intel', label: 'Fleet Analytics', icon: PieChart, path: '/fleet-operator?tab=analytics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] }, { id: 'clinical', label: 'Clinical Intelligence', diff --git a/src/main.tsx b/src/main.tsx index bef5202..65cf9e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,7 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - - - , + , ) diff --git a/src/pages/FleetLogin.tsx b/src/pages/FleetLogin.tsx index 69d22ba..c14b893 100644 --- a/src/pages/FleetLogin.tsx +++ b/src/pages/FleetLogin.tsx @@ -3,23 +3,20 @@ import { useNavigate, NavLink } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Truck, - Zap, ShieldCheck, Lock, User, ArrowRight, Cpu, - Radio, - Activity, KeyRound, ShieldAlert, Eye, EyeOff, - Crosshair, - Signal + MapPin, + Activity } from 'lucide-react'; 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 = () => { const [username, setUsername] = useState('fleet_operator'); @@ -28,7 +25,7 @@ export const FleetLogin = () => { const [isLoading, setIsLoading] = useState(false); const [showError, setShowError] = useState(''); const [mfaSessionToken, setMfaSessionToken] = useState(''); - const [tempUser, setTempUser] = useState(null); + const [tempUser, setTempUser] = useState | null>(null); const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login'); const [showPassword, setShowPassword] = useState(false); @@ -38,44 +35,76 @@ export const FleetLogin = () => { e.preventDefault(); setIsLoading(true); setShowError(''); - - // --- MOCK LOGIN FOR FLEET OPERATOR --- - if (username === 'fleet_operator' && password === 'Fleet@123') { - setTimeout(() => { - localStorage.setItem('teleems_auth', 'true'); - localStorage.setItem('teleems_token', 'mock-fleet-token-2026'); - localStorage.setItem('teleems_user', JSON.stringify({ - id: 'fleet-op-001', - username: 'fleet_operator', - roles: ['FLEET_OPERATOR'], - metadata: { - organization: { company_name: 'TeleEMS Fleet Services' } - } - })); - setIsLoading(false); - navigate('/fleet-operator'); - }, 1000); - return; - } + + // Clear any leftover mock tokens to ensure a real network call + console.log('--- STARTING FLEET LOGIN ---'); + console.log('Clearing local storage tokens...'); + localStorage.removeItem('teleems_token'); + localStorage.removeItem('teleems_auth'); + localStorage.removeItem('teleems_user'); try { - const response = await authApi.login(username, password); - - if (response.status === 201 || response.status === 200) { - if (response.data.mfa_required) { - setMfaSessionToken(response.data.mfa_session_token || ''); - setTempUser(response.data.user || null); + console.log('[FleetLogin] Logging in as:', username); + + // Clear old session first + localStorage.removeItem('teleems_token'); + localStorage.removeItem('teleems_user'); + 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'); } 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_token', response.data.access_token || ''); - localStorage.setItem('teleems_user', JSON.stringify(response.data.user || {})); - navigate('/fleet-operator'); + localStorage.setItem('teleems_token', accessToken); + console.log('[FleetLogin] Token stored. Fetching /auth/me...'); + + // 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 { - 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'); } finally { setIsLoading(false); @@ -93,14 +122,17 @@ export const FleetLogin = () => { if (response.status === 201 || response.status === 200) { localStorage.setItem('teleems_auth', 'true'); localStorage.setItem('teleems_token', response.data.access_token || ''); - const userToStore = response.data.user || tempUser || {}; - userToStore.mfa_enabled = true; + const baseUser: Record = (response.data.user || tempUser || {}) as Record; + 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)); - navigate('/fleet-operator'); + navigate('/fleet-operator?tab=organization'); } else { setShowError('Invalid Security Token'); } - } catch (err) { + } catch { setShowError('Token Verification Failed'); } finally { setIsLoading(false); @@ -108,189 +140,307 @@ export const FleetLogin = () => { }; return ( -
- {/* Tactical Background Elements */} -
-
-
+
+ + {/* LEFT SIDE - IMAGE & TACTICAL HUD */} +
+ {/* Background Image */} +
+ {/* Gradients to blend image with the tactical theme */} +
+
- {/* Decorative Radar/Circle */} - -
- - - -
+ {/* Decorative Grid & Radar (HUD elements) */} +
+ + {/* Content Top */} +
- +
+ +
+
+ TELE_EMS + FLEET_OPERATOR +
+
+ + {/* Content Middle/Bottom */} +
+ + Command The Fleet.
+ Save Lives Faster. +
+ + Access real-time telemetry, manage dispatch routes, and monitor critical resources from a single, secure tactical terminal. + -

- {loginStep === 'login' ? 'FLEET TERMINAL' : 'SECURE TOKEN'} -

-

- {loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification Required'} -

+ +
+ REAL-TIME SYNC +
+
+ GPS TRACKING +
+
+ ENCRYPTED +
+
+
- {loginStep === 'login' ? ( -
-
- -
- - setUsername(e.target.value)} - style={{ color: '#fff' }} - required - /> + {/* RIGHT SIDE - LOGIN FORM */} +
+ {/* Subtle background glow */} +
+ + +
+

+ {loginStep === 'login' ? 'TERMINAL ACCESS' : 'MFA REQUIRED'} +

+

+ {loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification'} +

+
+ + {loginStep === 'login' ? ( + +
+ +
+ + 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)'} + /> +
-
-
- -
- - setPassword(e.target.value)} - style={{ color: '#fff' }} - required - /> - +
+ +
+ + setPassword(e.target.value)} + required + style={{ + width: '100%', padding: '16px 48px 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", letterSpacing: showPassword ? 'normal' : '3px' + }} + onFocus={(e) => e.target.style.borderColor = '#22d3ee'} + onBlur={(e) => e.target.style.borderColor = 'rgba(34, 211, 238, 0.3)'} + /> + +
-
- - - ) : ( -
-
- -
- - setMfaCode(e.target.value.replace(/\D/g, ''))} - style={{ color: '#fff' }} - required - /> + + + ) : ( +
+
+ +
+ + setMfaCode(e.target.value.replace(/\D/g, ''))} + 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)'} + /> +
-
- - - )} - - - {showError && ( - - - {showError} - + + )} - -
- - SECURE UPLINK ESTABLISHED -
+ + {showError && ( + +
+ + {showError} +
+
+ )} +
-
- - BACK TO STANDARD LOGIN - -
- - {/* Page-level status indicators */} -
-
-
- COMMS_STRENGTH -
- {[1, 2, 3, 4].map(i =>
)} -
+ +
+ e.currentTarget.style.color = '#22d3ee'} onMouseOut={(e) => e.currentTarget.style.color = '#94a3b8'}> + RETURN TO STANDARD PORTAL +
-
- LIVE TELEMETRY SYNC -
-
+
-
-

TERMINAL_ID: DISPATCH-X7

-

PROTOCOL: CS-SECURE-v4

-

ENCRYPTION: QUANTUM-SAFE

-
+ {/* Embedded Global Styles to avoid breaking any other page, since we are overriding locally */} +
); }; diff --git a/src/pages/FleetOperatorDashboard.tsx b/src/pages/FleetOperatorDashboard.tsx index ecafa68..7e61749 100644 --- a/src/pages/FleetOperatorDashboard.tsx +++ b/src/pages/FleetOperatorDashboard.tsx @@ -1,339 +1,50 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React from 'react'; import { useSearchParams } from 'react-router-dom'; import { - Activity, - Truck, - Zap, - ShieldCheck, - MapPin, - Clock, - Navigation, - AlertTriangle, - Fuel, - Gauge, - Thermometer, - Wind, - Bell, - Settings, - ChevronRight, - LayoutGrid, - Route as RouteIcon, - Users, - Search, - CheckCircle2, - ShoppingCart + Building2, Truck, Users, CalendarDays, ClipboardCheck, + ShoppingCart, Map, MapPin, Navigation, Link2, Activity, + Bell, Settings, Search } from 'lucide-react'; -import { motion, AnimatePresence } 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'; +import { motion } from 'framer-motion'; -// --- NEW FLEET MODULES --- +import { FleetOrganization } from './fleet/FleetOrganization'; import { FleetAssets } from './fleet/FleetAssets'; import { FleetPersonnel } from './fleet/FleetPersonnel'; -import { FleetInventory } from './fleet/FleetInventory'; import { FleetScheduling } from './fleet/FleetScheduling'; +import { FleetInventory } from './fleet/FleetInventory'; -// --- MOCK DATA FOR THE ENHANCED FEEL --- -const MOCK_VEHICLES = [ - { id: 'V001', number: 'TN-01-AM-1024', status: 'EN_ROUTE', speed: 45, fuel: 82, lat: 13.0827, lng: 80.2707, type: 'ALS' }, - { id: 'V002', number: 'TN-05-AM-5521', status: 'IDLE', speed: 0, fuel: 65, lat: 13.0067, lng: 80.2575, type: 'BLS' }, - { id: 'V003', number: 'TN-07-AM-1122', status: 'TRANSPORTING', speed: 52, fuel: 45, lat: 12.9667, lng: 80.2475, type: 'TRANSFER' }, - { id: 'V004', number: 'TN-09-AM-9988', status: 'AT_SCENE', speed: 0, fuel: 78, lat: 12.9941, lng: 80.1709, type: 'AIR' }, +const SCOPE_MODULES = [ + { id: 'overview', label: 'Live Dashboard', icon: Map, desc: 'Real-time fleet tracking & telemetry' }, + { id: 'organization', label: 'Stations', icon: Building2, desc: 'Manage stations and profiles' }, + { id: 'assets', label: 'Vehicle Management', icon: Truck, desc: 'Registration, Docs, Services' }, + { id: 'personnel', label: 'Staff Management', icon: Users, desc: 'Pilots, EMTs, Doctors' }, + { 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 = [ - { time: '08:00', trips: 12, response: 14 }, - { time: '10:00', trips: 18, response: 12 }, - { time: '12:00', trips: 25, response: 18 }, - { time: '14:00', trips: 22, response: 15 }, - { time: '16:00', trips: 30, response: 22 }, - { time: '18:00', trips: 28, response: 19 }, -]; - -// --- LIVE MAP COMPONENT --- -const CommandMap: React.FC<{ vehicles: any[] }> = ({ vehicles }) => { - const [L, setL] = useState(null); - const mapRef = React.useRef(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: `
-
-
`, - iconSize: [24, 24], - iconAnchor: [12, 24] - }); - L.marker([v.lat, v.lng], { icon }).addTo(map).bindPopup(`${v.number}
Status: ${v.status}`); - }); - }, [L, vehicles]); - - return
; -}; +const PlaceholderModule: React.FC<{ label: string; icon: React.ElementType }> = ({ label, icon: Icon }) => ( +
+ +

{label}

+

This module is under active development.

+
+); export const FleetOperatorDashboard: React.FC = () => { const [searchParams] = useSearchParams(); - const activeTab = searchParams.get('tab') || 'overview'; - - const [isLoading, setIsLoading] = useState(true); - const [incidents, setIncidents] = useState([]); - const [vehicles, setVehicles] = useState(MOCK_VEHICLES); + const activeModule = searchParams.get('tab') || 'overview'; - useEffect(() => { - const fetchData = async () => { - try { - const token = localStorage.getItem('teleems_token') || ''; - 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 activeModuleInfo = SCOPE_MODULES.find(m => m.id === activeModule); - 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 ( -
- {/* Stats Grid */} -
- - - - -
- -
- {/* Left Column: Map and Fleet List */} -
- - -
-
-
-
- EN ROUTE -
-
-
- IDLE -
-
-
- AT SCENE -
-
-
- TRANSPORTING -
-
-
- HOSPITAL -
-
-
- BREAKDOWN -
-
-
-
- - -
- - - - - - - - - - - - {vehicles.map(v => ( - - - - - - - - ))} - -
VehicleStatusSpeedFuelActions
-
{v.number}
-
{v.type} UNIT
-
- {v.status} - {v.speed} km/h -
-
-
-
- -
-
-
-
- - {/* Right Column: Performance and Incidents */} -
- -
- - - - - - - - - - - - -
-
- - -
- {incidents.length > 0 ? incidents.map(inc => ( -
-
- #{inc.id.split('-').pop()?.toUpperCase()} - {inc.severity} -
-
{inc.category}
-
- {inc.address} -
-
- )) : ( -
No active incidents
- )} -
- -
- - -
-
- -
82%
-
AVAILABILITY
-
-
- -
94%
-
FUEL READINESS
-
-
-
-
-
-
- ); + const renderModuleContent = () => { + switch (activeModule) { + case 'organization': + return ; case 'assets': return ; case 'personnel': @@ -342,53 +53,95 @@ export const FleetOperatorDashboard: React.FC = () => { return ; case 'inventory': return ; + case 'overview': + return ; + case 'attendance': + return ; + case 'trips': + return ; + case 'telematics': + return ; + case 'referrals': + return ; case 'analytics': - return
Fleet Intelligence Reports Loading...
; + return ; default: - return null; + return ; } }; return ( -
- {/* Main Content Area */} -
- {/* Header */} -
-
-

- {menuItems.find(m => m.id === activeTab)?.label || 'Fleet Command'} -

-
-
- Strategic Operations • Live Platform Telemetry -
-
-
-
-
-
Tactical Time
-
{new Date().toLocaleTimeString()}
-
- -
- - +
+

+ {activeModuleInfo?.label || 'Fleet Command'} +

+
+
+ {activeModuleInfo?.desc || 'Secure Connection • TeleEMS Fleet'}
- {renderContent()} +
+
+ + +
+ + +
+
+
+
Station Incharge
+
Fleet Operator
+
+
+ +
+
+
+ + + {/* Scrollable Content */} +
+ + {renderModuleContent()} +
); diff --git a/src/pages/fleet/FleetAssets.tsx b/src/pages/fleet/FleetAssets.tsx index 8056772..35b213d 100644 --- a/src/pages/fleet/FleetAssets.tsx +++ b/src/pages/fleet/FleetAssets.tsx @@ -1,207 +1,607 @@ -import React, { useState } from 'react'; -import { - Plus, - Search, - Filter, - Truck, - FileText, - Wrench, - Calendar, - AlertTriangle, - ExternalLink, - ChevronRight, - ShieldCheck, - Fuel, - Gauge +import React, { useState, useEffect, useCallback } from 'react'; +import { + Plus, Search, Truck, X, Loader2, CheckCircle, + AlertCircle, ChevronDown, Edit2 } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Card } from '../../components/Common'; + +interface Station { + id: string; + name: string; +} interface Vehicle { id: string; - number: string; - type: 'ALS' | 'BLS' | 'TRANSPORT'; - model: string; - station: string; - status: 'ACTIVE' | 'MAINTENANCE' | 'BREAKDOWN' | 'OFF_DUTY'; - docs: { - rc: string; - fc: string; - insurance: string; - permit: string; - }; - lastService: string; - nextService: string; - fuel: number; + registration_number: string; + vehicle_type: string; + brand?: string; + model?: string; + chassis_number?: string; + station_id?: string; + status?: string; } -const MOCK_FLEET: Vehicle[] = [ - { 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 }, - { 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 }, - { 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 }, - { 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 }, -]; +interface VehicleForm { + registration_number: string; + vehicle_type: string; + chassis_number: string; + 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 = () => { const [searchQuery, setSearchQuery] = useState(''); - const [selectedVehicle, setSelectedVehicle] = useState(null); + const [vehicles, setVehicles] = useState([]); + const [stations, setStations] = useState([]); + const [stationsLoading, setStationsLoading] = useState(false); + const [loading, setLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [editingVehicle, setEditingVehicle] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [editForm, setEditForm] = useState(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 ( -
+
+ + {/* Toast */} + + {toast && ( + + {toast.type === 'success' ? : } + {toast.message} + + )} + + + {/* Register Vehicle Modal */} + + {showModal && ( + { if (e.target === e.currentTarget) setShowModal(false); }} + > + + {/* Modal Header */} +
+
+

Register New Vehicle

+

Add an ambulance to your fleet

+
+ +
+ +
+
+ + {/* Registration Number */} +
+ + setForm(f => ({ ...f, registration_number: e.target.value }))} + /> +
+ + {/* Vehicle Type */} +
+ + + +
+ + {/* Assign to Station */} +
+ + {stationsLoading ? ( +
+ Loading stations... +
+ ) : stations.length === 0 ? ( +
+ ⚠ No stations found. Please create a station first. +
+ ) : ( + <> + + + + )} + +
+ + {/* Brand & Model */} +
+
+ + setForm(f => ({ ...f, brand: e.target.value }))} + /> +
+
+ + setForm(f => ({ ...f, model: e.target.value }))} + /> +
+
+ + {/* Chassis Number */} +
+ + setForm(f => ({ ...f, chassis_number: e.target.value }))} + /> +
+ + +
+ + {/* Actions */} +
+ + +
+
+
+
+ )} +
+ + {/* Edit Vehicle Modal */} + + {showEditModal && editingVehicle && ( + { if (e.target === e.currentTarget) setShowEditModal(false); }} + > + +
+
+

Edit Vehicle

+

Update details for {editingVehicle.registration_number}

+
+ +
+ +
+
+ +
+ + setEditForm(f => ({ ...f, registration_number: e.target.value }))} /> +
+ +
+ + + +
+ +
+ + {stationsLoading ? ( +
+ Loading stations... +
+ ) : ( + <> + + + + )} +
+ +
+
+ + setEditForm(f => ({ ...f, brand: e.target.value }))} /> +
+
+ + setEditForm(f => ({ ...f, model: e.target.value }))} /> +
+
+ +
+ + setEditForm(f => ({ ...f, chassis_number: e.target.value }))} /> +
+ +
+ +
+ + +
+
+
+
+ )} +
+ + {/* Header */}
-
-
- - setSearchQuery(e.target.value)} - /> -
- +
+ + setSearchQuery(e.target.value)} + />
-
-
- {/* Fleet Inventory Grid */} -
-
- {MOCK_FLEET.map((v) => ( - setSelectedVehicle(v)} - style={{ - cursor: 'pointer', - border: selectedVehicle?.id === v.id ? '2px solid var(--accent-cyan)' : '1px solid var(--card-border)', - background: selectedVehicle?.id === v.id ? 'rgba(59, 130, 246, 0.05)' : 'rgba(15, 23, 42, 0.4)' - }} + {/* Loading */} + {loading && ( +
+ + Loading vehicles... +
+ )} + + {/* Empty state */} + {!loading && filtered.length === 0 && ( +
+ +

No Vehicles Registered

+

Click "Register New Vehicle" to add your first ambulance.

+
+ )} + + {/* Vehicle Cards */} + {!loading && filtered.length > 0 && ( +
+ {filtered.map(v => { + const sc = getStatusColor(v.status); + return ( + { (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'; }} > +
-
{v.type} UNIT • {v.id}
-

{v.number}

-
{v.model}
-
-
- {v.status} +
{v.vehicle_type} UNIT
+

{v.registration_number}

+ {(v.brand || v.model) && ( +
{v.brand} {v.model}
+ )}
+ {v.status && ( +
+ {v.status} +
+ )}
-
-
-
Fuel Level
-
-
-
-
- {v.fuel}% -
+ {v.chassis_number && ( +
+ CH: {v.chassis_number}
-
+ )} -
-
-
-
-
-
- {v.station} -
- - ))} -
-
- - {/* Detailed Inspector Panel */} -
- {selectedVehicle ? ( - - - -
- {/* Critical Document Status */} -
-

- Document Vault -

-
- {[ - { 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) => ( -
-
-
{doc.label}
-
Expires: {doc.expiry}
-
-
- {doc.status === 'VALID' ? : } - {doc.status} -
-
- ))} -
-
- - {/* Maintenance History */} -
-

- Service Records -

-
-
-
- Upcoming Service - {selectedVehicle.nextService} -
-
Scheduled for: Engine Oil change, Brake pad inspection, and AC filter cleaning.
-
-
-
Last Major Service
-
{selectedVehicle.lastService}
-
-
-
- -
- - -
-
-
+
-
- ) : ( -
-
- -
-

No Asset Selected

-

Select a vehicle from the fleet inventory to view tactical diagnostics, documents, and service history.

-
- )} + ); + })}
-
+ )} + +
); }; diff --git a/src/pages/fleet/FleetOrganization.tsx b/src/pages/fleet/FleetOrganization.tsx new file mode 100644 index 0000000..a13f39e --- /dev/null +++ b/src/pages/fleet/FleetOrganization.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [editingStationId, setEditingStationId] = useState(null); + const [loadingDetailsId, setLoadingDetailsId] = useState(null); + const [form, setForm] = useState(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(null); + const mapInstanceRef = useRef(null); + const markerRef = useRef(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 ( +
+ {/* Toast Notification */} + + {toast && ( + + {toast.type === 'success' ? : } + {toast.message} + + )} + + + {/* Add Station Modal */} + {createPortal( + + {showModal && ( + { if (e.target === e.currentTarget) setShowModal(false); }} + > + +
+
+

{editingStationId ? 'Edit Station' : 'Add New Station'}

+

{editingStationId ? 'Update dispatch station details' : 'Create a new dispatch station'}

+
+ +
+ +
+
+ + {/* Left Column: Form Fields */} +
+
+ + setForm(f => ({ ...f, name: e.target.value }))} /> +
+
+ +