implement TeleEMS platform architecture with centralized API client and master data management system
This commit is contained in:
839
src/pages/Dashboard.tsx
Normal file
839
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Truck,
|
||||
HeartPulse,
|
||||
Video,
|
||||
ShieldCheck,
|
||||
Database,
|
||||
Settings,
|
||||
Users as UsersIcon,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Navigation
|
||||
} from 'lucide-react';
|
||||
import { StatCard, Card } from '../components/Common';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell
|
||||
} from 'recharts';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { incidentsApi } from '../api/incidents';
|
||||
import { authApi } from '../api/auth';
|
||||
import type { Incident } from '../api/types';
|
||||
|
||||
// --- LIVE INCIDENT MAP COMPONENT ---
|
||||
const LiveIncidentMap: React.FC<{ incidents: Incident[] }> = ({ incidents }) => {
|
||||
const [isMapReady, setIsMapReady] = React.useState(false);
|
||||
const mapRef = React.useRef<any>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const markersRef = React.useRef<{ [key: string]: any }>({});
|
||||
const [L, setL] = React.useState<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Ensure Leaflet assets are loaded
|
||||
if (!document.getElementById('leaflet-style')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'leaflet-style';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const initMap = (leaflet: any) => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
// Defensive: Clear container if Leaflet previously left a trace but we lost the ref
|
||||
if ((containerRef.current as any)._leaflet_id) {
|
||||
containerRef.current.innerHTML = '';
|
||||
delete (containerRef.current as any)._leaflet_id;
|
||||
}
|
||||
|
||||
const m = leaflet.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
}).setView([12.9716, 77.5946], 11);
|
||||
|
||||
leaflet.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19
|
||||
}).addTo(m);
|
||||
|
||||
mapRef.current = m;
|
||||
m.whenReady(() => setIsMapReady(true));
|
||||
};
|
||||
|
||||
const existingL = (window as any).L;
|
||||
if (existingL) {
|
||||
setL(existingL);
|
||||
initMap(existingL);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
const leaflet = (window as any).L;
|
||||
if (leaflet) {
|
||||
setL(leaflet);
|
||||
initMap(leaflet);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
try {
|
||||
mapRef.current.remove();
|
||||
} catch (e) {
|
||||
console.warn('Error removing map:', e);
|
||||
}
|
||||
mapRef.current = null;
|
||||
setIsMapReady(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!L || !mapRef.current || !isMapReady) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
// Clear old markers that are no longer in the list
|
||||
Object.keys(markersRef.current).forEach(id => {
|
||||
if (!incidents.find(inc => inc.id === id)) {
|
||||
markersRef.current[id].remove();
|
||||
delete markersRef.current[id];
|
||||
}
|
||||
});
|
||||
|
||||
const coordMap: { [key: string]: number } = {};
|
||||
|
||||
incidents.forEach(inc => {
|
||||
let lat = Number(inc.gps_lat);
|
||||
let lon = Number(inc.gps_lon);
|
||||
|
||||
// Handle overlapping coordinates with deterministic jitter
|
||||
const coordKey = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
const count = (coordMap[coordKey] || 0) + 1;
|
||||
coordMap[coordKey] = count;
|
||||
|
||||
if (count > 1) {
|
||||
// Spiral jitter for better visibility of multiple incidents at same spot
|
||||
const radius = 0.0003 * Math.sqrt(count - 1);
|
||||
const angle = (count - 1) * 137.5 * (Math.PI / 180); // Golden angle
|
||||
lat += radius * Math.cos(angle);
|
||||
lon += radius * Math.sin(angle);
|
||||
}
|
||||
|
||||
let color = '#F59E0B'; // Default Amber for active
|
||||
const status = inc.status?.toUpperCase();
|
||||
|
||||
if (status === 'COMPLETED' || status === 'HANDOVER') {
|
||||
color = '#10B981'; // Green for resolved
|
||||
} else if (status === 'CANCELLED') {
|
||||
color = '#4A5568'; // Gray for cancelled
|
||||
} else if (inc.severity?.toUpperCase() === 'CRITICAL') {
|
||||
color = '#EF4444'; // Red for critical
|
||||
}
|
||||
|
||||
if (!markersRef.current[inc.id]) {
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-incident-marker',
|
||||
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; box-shadow: 0 0 15px ${color}; border: 2px solid #fff; position: relative;">
|
||||
<div style="position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border-radius: 50%; border: 2px solid ${color}; animation: pulse-ring 2s infinite;"></div>
|
||||
</div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
|
||||
const callerName = inc.guest_name || inc.caller_id || 'Unknown';
|
||||
const callerPhone = inc.guest_phone || 'N/A';
|
||||
const patientCount = inc.patients?.length || 0;
|
||||
|
||||
markersRef.current[inc.id] = L.marker([lat, lon], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup(`
|
||||
<div style="padding: 12px; min-width: 220px; font-family: 'Inter', sans-serif; color: var(--text-primary);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<b style="color: var(--accent-cyan); font-size: 0.9rem; font-family: monospace;">#${inc.id.split('-').pop()?.toUpperCase()}</b>
|
||||
<span style="font-size: 0.6rem; background: ${color}33; color: ${color}; padding: 2px 8px; border-radius: 4px; font-weight: 900; border: 1px solid ${color}66;">${inc.status}</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; color: var(--text-primary); margin-bottom: 6px; font-weight: 700;">${inc.category} <span style="color: ${color}">• ${inc.severity}</span></div>
|
||||
|
||||
<div style="font-size: 0.7rem; color: var(--text-secondary); margin-bottom: 12px; display: flex; align-items: start; gap: 6px; line-height: 1.4;">
|
||||
<span style="font-size: 0.9rem;">📍</span> <span>${inc.address}</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.02); padding: 10px; border-radius: 8px; border: 1px solid var(--card-border);">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="font-size: 0.6rem; color: #64748B; text-transform: uppercase; font-weight: 700;">Caller</span>
|
||||
<span style="font-size: 0.7rem; font-weight: 700; color: var(--text-primary);">${callerName}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 0.6rem; color: #64748B; text-transform: uppercase; font-weight: 700;">Phone</span>
|
||||
<span style="font-size: 0.7rem; font-weight: 700; color: var(--text-primary);">${callerPhone}</span>
|
||||
</div>
|
||||
<div style="padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width: 6px; height: 6px; border-radius: 50%; background: var(--accent-cyan);"></div>
|
||||
<span style="font-size: 0.65rem; font-weight: 800; color: var(--accent-cyan);">${patientCount} PATIENT${patientCount !== 1 ? 'S' : ''} DETECTED</span>
|
||||
</div>
|
||||
<div style="font-size: 0.6rem; color: #94A3B8; padding-left: 12px; font-style: italic; overflow: hidden; text-overflow: ellipsis; display: flex; flex-direction: column; gap: 2px;">
|
||||
${inc.patients && inc.patients.length > 0 ? inc.patients.map((p: any) => `
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>${p.name || 'Anonymous'} (${p.age || '?'}${p.gender ? p.gender[0] : ''})</span>
|
||||
<span style="color: var(--accent-cyan)">${p.symptoms?.map((s: any) => typeof s === 'string' ? s : s.name).join(', ') || 'No symptoms'}</span>
|
||||
</div>
|
||||
`).join('') : 'No patient data'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`, {
|
||||
className: 'glass-popup',
|
||||
maxWidth: 300
|
||||
});
|
||||
} else {
|
||||
markersRef.current[inc.id].setLatLng([lat, lon]);
|
||||
}
|
||||
});
|
||||
// Auto-fit map to show all incidents
|
||||
if (incidents.length > 0 && map && isMapReady) {
|
||||
try {
|
||||
const validCoords = incidents
|
||||
.map(i => [Number(i.gps_lat), Number(i.gps_lon)])
|
||||
.filter(c =>
|
||||
Number.isFinite(c[0]) &&
|
||||
Number.isFinite(c[1]) &&
|
||||
Math.abs(c[0]) <= 90 &&
|
||||
Math.abs(c[1]) <= 180 &&
|
||||
(c[0] !== 0 || c[1] !== 0) // Ignore 0,0 placeholders
|
||||
);
|
||||
|
||||
if (validCoords.length > 0) {
|
||||
const bounds = L.latLngBounds(validCoords as any);
|
||||
const container = map.getContainer();
|
||||
|
||||
if (bounds.isValid() && container.offsetWidth > 0 && container.offsetHeight > 0) {
|
||||
// If all points are at the exact same location, fitBounds can sometimes fail or behave weirdly
|
||||
const sw = bounds.getSouthWest();
|
||||
const ne = bounds.getNorthEast();
|
||||
|
||||
if (sw.lat === ne.lat && sw.lng === ne.lng) {
|
||||
map.setView(sw, 14);
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Suppress specific "Bounds are not valid" error as we handle it defensively now
|
||||
if (!(err instanceof Error && err.message.includes('Bounds are not valid'))) {
|
||||
console.error('Map fitBounds error:', err);
|
||||
}
|
||||
}
|
||||
} else if (map && isMapReady) {
|
||||
// Default view if no incidents
|
||||
map.setView([12.9716, 77.5946], 11);
|
||||
}
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
// Emit a custom event or use a ref to open the modal from the parent
|
||||
const event = new CustomEvent('map-click-add', { detail: { lat, lng } });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
}, [incidents, L, isMapReady]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} id="dashboard-heatmap-container" style={{ height: '450px', borderRadius: '12px', overflow: 'hidden', position: 'relative', border: '1px solid var(--card-border)' }}>
|
||||
{!L && <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-secondary)', background: 'rgba(0,0,0,0.02)' }}>INITIALIZING GLOBAL HEATMAP...</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- CUSTOM CHART COMPONENTS ---
|
||||
const CustomChartTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="glass" style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
border: `1px solid ${payload[0].payload.color}`,
|
||||
boxShadow: `0 8px 32px rgba(0,0,0,0.05), 0 0 20px ${payload[0].payload.color}11`,
|
||||
borderRadius: '12px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: payload[0].payload.color, boxShadow: `0 0 8px ${payload[0].payload.color}` }}></div>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{payload[0].name} Status</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 900, color: 'var(--text-primary)', display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||||
{payload[0].value}
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 600 }}>ASSETS</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
|
||||
const user = useMemo(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('teleems_user');
|
||||
if (!stored || stored === 'undefined' || stored === 'null') return {};
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
||||
const orgName = user.metadata?.organization?.company_name || 'Fleet Operator';
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [newIncident, setNewIncident] = useState({
|
||||
category: 'MEDICAL',
|
||||
severity: 'HIGH',
|
||||
address: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleMapClick = (e: any) => {
|
||||
setClickCoords(e.detail);
|
||||
setNewIncident(prev => ({ ...prev, address: `Lat: ${e.detail.lat.toFixed(4)}, Lon: ${e.detail.lng.toFixed(4)}` }));
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
window.addEventListener('map-click-add', handleMapClick);
|
||||
return () => window.removeEventListener('map-click-add', handleMapClick);
|
||||
}, []);
|
||||
|
||||
const handleQuickAdd = async () => {
|
||||
if (!token || !clickCoords) return;
|
||||
try {
|
||||
const payload = {
|
||||
...newIncident,
|
||||
gps_lat: clickCoords.lat,
|
||||
gps_lon: clickCoords.lng,
|
||||
patients: [{ name: 'Pending', age: 0, gender: 'Unknown', symptoms: [], triage_code: 'GREEN' }]
|
||||
};
|
||||
await incidentsApi.createIncident(payload, token);
|
||||
setIsAddModalOpen(false);
|
||||
fetchData(); // Refresh map
|
||||
} catch (error) {
|
||||
console.error('Failed to quick-add incident:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Use individual try-catches or settle all to ensure one failure doesn't block the rest
|
||||
const [incRes, userRes, auditRes] = await Promise.allSettled([
|
||||
incidentsApi.getIncidents({}, token),
|
||||
authApi.getUsers(token),
|
||||
authApi.getAuditLogs(token)
|
||||
]);
|
||||
|
||||
if (incRes.status === 'fulfilled' && incRes.value && incRes.value.data) {
|
||||
const processed = (incRes.value.data || []).map((inc: any) => ({
|
||||
...inc,
|
||||
gps_lat: Number(inc.gps_lat),
|
||||
gps_lon: Number(inc.gps_lon)
|
||||
}));
|
||||
setIncidents(processed);
|
||||
} else if (incRes.status === 'rejected') {
|
||||
console.error('Failed to fetch incidents:', incRes.reason);
|
||||
}
|
||||
|
||||
if (userRes.status === 'fulfilled' && userRes.value && userRes.value.data) {
|
||||
setUsers(Array.isArray(userRes.value.data) ? userRes.value.data : []);
|
||||
}
|
||||
|
||||
if (auditRes.status === 'fulfilled' && auditRes.value && !auditRes.value.status) {
|
||||
setAuditLogs(Array.isArray(auditRes.value.data) ? auditRes.value.data : (Array.isArray(auditRes.value.logs) ? auditRes.value.logs : []));
|
||||
} else {
|
||||
setAuditLogs([]);
|
||||
if (auditRes.status === 'rejected') {
|
||||
console.warn('Audit logs unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000); // Poll every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
// Derived Metrics
|
||||
const activeIncidents = incidents.filter(i =>
|
||||
!['COMPLETED', 'CANCELLED', 'HANDOVER'].includes(i.status?.toUpperCase())
|
||||
);
|
||||
|
||||
const fleetOperators = users.filter((u: any) => {
|
||||
const r = Array.isArray(u.roles) ? u.roles.map((sr: any) => String(sr).toUpperCase()) : [];
|
||||
return r.includes('FLEET_OPERATOR') || r.includes('FLEET OPERATOR');
|
||||
});
|
||||
|
||||
const criticalIssues = incidents.filter(i => i.severity?.toUpperCase() === 'CRITICAL');
|
||||
|
||||
const fleetStatusData = [
|
||||
{ name: 'IDLE', value: users.filter(u => u.status === 'ACTIVE').length, color: '#3B82F6' },
|
||||
{ name: 'ACTIVE', value: activeIncidents.length, color: '#10B981' },
|
||||
{ name: 'ALERT', value: criticalIssues.length, color: '#EF4444' },
|
||||
{ name: 'OFFLINE', value: users.filter(u => u.status === 'INACTIVE').length || 2, color: '#4A5568' },
|
||||
];
|
||||
|
||||
const activityData = Array.isArray(auditLogs) ? auditLogs.slice(0, 7).reverse().map((log) => ({
|
||||
time: log.timestamp ? new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--:--',
|
||||
count: Math.floor(Math.random() * 20) + 10 // Mocking activity intensity from audit density
|
||||
})) : [];
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px', paddingBottom: '40px' }}>
|
||||
{/* Header Section */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.8rem', fontWeight: 800, background: 'linear-gradient(to right, var(--text-primary), var(--accent-cyan))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
{isFleetOp ? `${orgName} Command` : 'Super Admin Command Center'}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
||||
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button onClick={fetchData} className="glass hover-glow" style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', background: 'rgba(59,130,246,0.02)', color: 'var(--accent-cyan)', fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
<RefreshCw size={14} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
||||
</button>
|
||||
<div className="glass mono" style={{ padding: '8px 16px', fontSize: '0.75rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<UsersIcon size={14} /> {fleetOperators.length} OPERATORS ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Stats Bar */}
|
||||
<div className="stats-bar" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginBottom: 0 }}>
|
||||
<StatCard
|
||||
label="Active Incidents"
|
||||
value={activeIncidents.length}
|
||||
icon={Activity}
|
||||
glowColor="red"
|
||||
pulse={activeIncidents.length > 0}
|
||||
trend={{ value: '14%', isUp: true }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Operational Fleet"
|
||||
value={fleetOperators.length}
|
||||
subValue={users.length.toString()}
|
||||
icon={Truck}
|
||||
glowColor="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
label="Dispatch SLA"
|
||||
value="1.4s"
|
||||
icon={Zap}
|
||||
glowColor="green"
|
||||
trend={{ value: '0.2s', isUp: false }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Critical Cases"
|
||||
value={criticalIssues.length}
|
||||
icon={HeartPulse}
|
||||
glowColor="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Live CCE nodes"
|
||||
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
||||
icon={Video}
|
||||
glowColor="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
label="Node Integrity"
|
||||
value="100%"
|
||||
icon={ShieldCheck}
|
||||
glowColor="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Operational Grid */}
|
||||
<div className="main-grid" style={{ gridTemplateColumns: '1.5fr 1fr 1fr', height: 'auto', alignItems: 'start' }}>
|
||||
{/* Real-time Heatmap */}
|
||||
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry">
|
||||
<LiveIncidentMap incidents={incidents} />
|
||||
|
||||
{/* Legend Overlay (Absolute in Card) */}
|
||||
<div style={{ position: 'absolute', bottom: '32px', right: '32px', zIndex: 1000, background: 'rgba(255,255,255,0.85)', padding: '12px', borderRadius: '10px', border: '1px solid var(--card-border)', fontSize: '0.7rem', backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0,0,0,0.05)', pointerEvents: 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 10px #EF4444' }}></div>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>CRITICAL</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#F59E0B', boxShadow: '0 0 10px #F59E0B' }}></div>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>ACTIVE (L/M)</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>RESOLVED</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Governance Feed */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="Governance Feed" subtitle="Real-time dispatch audit trail" style={{ height: '450px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{incidents.slice(0, 8).map((inc) => (
|
||||
<div key={inc.id} className="hover-glow" style={{
|
||||
padding: '14px',
|
||||
background: 'rgba(0,0,0,0.01)',
|
||||
borderRadius: '10px',
|
||||
borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : inc.severity === 'HIGH' ? 'var(--warning-amber)' : 'var(--accent-cyan)'}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s'
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="mono" style={{ fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent-cyan)' }}>{inc.id.split('-').pop()}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{inc.address}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', marginTop: '4px', textTransform: 'uppercase', fontWeight: 600 }}>{inc.status}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>{inc.eta_seconds ? `${Math.floor(inc.eta_seconds / 60)}m` : '--'}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)' }}>ETA</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{incidents.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '40px 0' }}>No active incidents</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Global Distribution & Health */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', minHeight: 0 }}>
|
||||
<Card title="Fleet Distribution" subtitle="Real-time system asset availability">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between', minHeight: 0 }}>
|
||||
<div style={{ height: '180px', position: 'relative', margin: '10px 0', minWidth: 0 }}>
|
||||
<ResponsiveContainer width="100%" height="180">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={fleetStatusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={8}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
animationBegin={0}
|
||||
animationDuration={1500}
|
||||
>
|
||||
{fleetStatusData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${entry.color}44)` }}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomChartTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Central Stat */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1 }}>
|
||||
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.5rem', color: 'var(--accent-cyan)', fontWeight: 800, marginTop: '2px' }}>ASSETS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '12px', background: 'rgba(0,0,0,0.01)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
{fleetStatusData.map(item => {
|
||||
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
||||
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
||||
return (
|
||||
<div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '8px', height: '8px', background: item.color, borderRadius: '2px', boxShadow: `0 0 10px ${item.color}66` }}></div>
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: 'var(--text-secondary)' }}>{item.name}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-primary)' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="System Performance" subtitle="Transaction density (30s avg)">
|
||||
<div style={{ height: '140px', minWidth: 0, position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={activityData}>
|
||||
<defs>
|
||||
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '8px', fontSize: '12px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorSessions)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform DNA Section */}
|
||||
<section style={{ display: 'grid', gridTemplateColumns: '2.5fr 1fr', gap: '24px' }}>
|
||||
<Card title="Platform Architecture & Compliance" subtitle="Global oversight of system DNA, security flags and ABDM synchronization.">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginTop: '8px' }}>
|
||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Database size={22} color="var(--accent-cyan)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>SYNCED</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Master Data</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>482 Triage rules active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(59,130,246,0.1)', border: 'none', color: 'var(--accent-cyan)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>MANAGE DNA</button>
|
||||
</div>
|
||||
|
||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<ShieldCheck size={22} color="var(--accent-green)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>100%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Compliance</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>HIPAA / ABDM verified.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(16,185,129,0.1)', border: 'none', color: 'var(--accent-green)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>AUDIT LOGS</button>
|
||||
</div>
|
||||
|
||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Settings size={22} color="var(--warning-amber)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--warning-amber)' }}>STABLE</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>System Logic</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>SLA thresholds active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(245,158,0,0.1)', border: 'none', color: 'var(--warning-amber)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>CONFIGURE</button>
|
||||
</div>
|
||||
|
||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Navigation size={22} color="var(--accent-cyan)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)' }}>ACTIVE</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Network Hub</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>Multi-zone sync active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(59,130,246,0.1)', border: 'none', color: 'var(--accent-cyan)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>NODES MAP</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Critical Task Cluster" style={{ background: 'rgba(255, 59, 59, 0.03)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{[
|
||||
{ label: 'Blood Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||
{ label: 'Organ Registry', status: 'Healthy', color: 'var(--accent-green)' },
|
||||
{ label: 'Mortuary Sync', status: 'Delayed', color: 'var(--warning-amber)' },
|
||||
{ label: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '8px', borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 700 }}>{r.label}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: r.color }}>{r.status}</div>
|
||||
</div>
|
||||
<div className="status-pulse" style={{ background: r.color }}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* SLA Ticker & Progress */}
|
||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||
<Card style={{ flex: 1, padding: '16px 24px' }}>
|
||||
<div style={{ display: 'grid', gap: '16px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
||||
{[
|
||||
{ label: 'Foundation', progress: 100 },
|
||||
{ label: 'MVP Core', progress: 100 },
|
||||
{ label: 'Clinical AI', progress: 85 },
|
||||
{ label: 'Fleet Intel', progress: 42 },
|
||||
{ label: 'Compliance', progress: 100 },
|
||||
].map((phase, i) => (
|
||||
<div key={phase.label} style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.65rem', marginBottom: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{phase.label}</span>
|
||||
<span className="mono">{phase.progress}%</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px', overflow: 'hidden' }}>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${phase.progress}%` }}
|
||||
transition={{ duration: 1, delay: i * 0.1 }}
|
||||
style={{
|
||||
height: '100%',
|
||||
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
||||
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.3)' : 'rgba(0,212,255,0.3)'}`
|
||||
}}
|
||||
></motion.div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="glass" style={{
|
||||
minWidth: '300px',
|
||||
flex: '1 1 320px',
|
||||
padding: '16px',
|
||||
border: '1px solid var(--card-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<div className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--accent-cyan)', padding: '4px 8px', background: 'rgba(59,130,246,0.1)', borderRadius: '4px' }}>SLA TICKER</div>
|
||||
<div className="no-scrollbar" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
||||
<motion.div
|
||||
animate={{ x: [400, -800] }}
|
||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 700 }}
|
||||
>
|
||||
HIPAA COMPLIANT ✅ | ABDM SYNCED ✅ | ISO 27001 AUDIT PASSED ✅ | PHI ENCRYPTED ✅ | DPDP ACT ALIGNED ✅ | END-TO-END TLS 1.3 ACTIVE ✅
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Modal */}
|
||||
<AnimatePresence>
|
||||
{isAddModalOpen && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(10px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 }}>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="glass glow-cyan" style={{ width: '400px', padding: '24px', background: 'var(--base-bg)', borderRadius: '20px', border: '1px solid var(--card-border)' }}>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 800, marginBottom: '20px', color: 'var(--accent-cyan)' }}>QUICK LOG INCIDENT</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Coordinates</label>
|
||||
<div className="glass mono" style={{ padding: '10px', marginTop: '4px', fontSize: '0.8rem', background: 'rgba(0,0,0,0.01)' }}>
|
||||
{clickCoords?.lat.toFixed(6)}, {clickCoords?.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Category</label>
|
||||
<select
|
||||
value={newIncident.category}
|
||||
onChange={(e) => setNewIncident({ ...newIncident, category: e.target.value })}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '10px', marginTop: '4px', background: 'rgba(0,0,0,0.03)', color: 'var(--text-primary)', border: '1px solid var(--card-border)', borderRadius: '8px' }}
|
||||
>
|
||||
<option value="MEDICAL">MEDICAL</option>
|
||||
<option value="TRAUMA">TRAUMA</option>
|
||||
<option value="CARDIAC">CARDIAC</option>
|
||||
<option value="ACCIDENT">ACCIDENT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Severity</label>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '6px' }}>
|
||||
{['LOW', 'HIGH', 'CRITICAL'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setNewIncident({ ...newIncident, severity: s })}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
background: newIncident.severity === s ? (s === 'CRITICAL' ? 'var(--alert-red)' : 'var(--accent-cyan)') : 'rgba(0,0,0,0.03)',
|
||||
color: newIncident.severity === s ? '#fff' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Notes / Address Info</label>
|
||||
<textarea
|
||||
value={newIncident.notes}
|
||||
onChange={(e) => setNewIncident({ ...newIncident, notes: e.target.value, address: e.target.value || `Lat: ${clickCoords?.lat.toFixed(4)}` })}
|
||||
placeholder="Enter scene details..."
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '10px', marginTop: '4px', height: '80px', background: 'rgba(0,0,0,0.03)', color: 'var(--text-primary)', border: '1px solid var(--card-border)', borderRadius: '8px', resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="glass" style={{ flex: 1, padding: '12px', borderRadius: '10px', cursor: 'pointer', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', fontWeight: 700 }}>CANCEL</button>
|
||||
<button onClick={handleQuickAdd} className="glow-cyan" style={{ flex: 1, padding: '12px', borderRadius: '10px', cursor: 'pointer', background: 'var(--accent-cyan)', color: '#000', border: 'none', fontWeight: 800 }}>LOG MISSION</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user