863 lines
34 KiB
TypeScript
863 lines
34 KiB
TypeScript
/**
|
|
* Active Map Page
|
|
*
|
|
* Displays hospitals and PHCC locations on an interactive map with markers showing:
|
|
* - Asset counts
|
|
* - Work Order counts (Normal/Urgent, by status)
|
|
* - Maintenance Log counts (Planned/Completed/Overdue)
|
|
*
|
|
* Supports both Hospital and PHCC location types with different field mappings:
|
|
* - Hospital: company field for assets/work orders, custom_hospital_name for maintenance
|
|
* - PHCC: custom_site for assets, site_name for work orders, asset-based for maintenance
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { MapContainer, TileLayer, Marker, Popup, Tooltip, useMap } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import apiService from '../services/apiService';
|
|
import LinkField from '../components/LinkField';
|
|
import useUserDashboardFilters from '../hooks/useUserDashboardFilters';
|
|
import { isSiteEnabledHospital, buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
|
|
|
|
// Fix for default marker icons in React-Leaflet
|
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
|
|
|
const DefaultIcon = L.icon({
|
|
iconUrl: icon,
|
|
shadowUrl: iconShadow,
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
tooltipAnchor: [16, -28],
|
|
shadowSize: [41, 41]
|
|
});
|
|
|
|
L.Marker.prototype.options.icon = DefaultIcon;
|
|
|
|
interface LocationData {
|
|
name: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
location_type: 'hospital' | 'phcc';
|
|
assets: number;
|
|
normal_work_orders: number;
|
|
urgent_work_orders: number;
|
|
planned_maintenance: number;
|
|
completed_maintenance: number;
|
|
overdue_maintenance: number;
|
|
wo_open: number;
|
|
wo_progress: number;
|
|
wo_review: number;
|
|
wo_completed: number;
|
|
wo_closed: number;
|
|
phcc_asset_names?: string[];
|
|
}
|
|
|
|
// Component to handle map bounds fitting
|
|
const MapBounds: React.FC<{ locations: LocationData[] }> = ({ locations }) => {
|
|
const map = useMap();
|
|
|
|
useEffect(() => {
|
|
if (locations.length > 0 && locations.some(l => l.latitude && l.longitude)) {
|
|
const bounds = L.latLngBounds(
|
|
locations
|
|
.filter(l => l.latitude && l.longitude)
|
|
.map(l => [l.latitude, l.longitude] as [number, number])
|
|
);
|
|
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 8 });
|
|
} else {
|
|
// Fallback: show Saudi Arabia center
|
|
map.setView([24.8, 45.5], 6);
|
|
}
|
|
}, [locations, map]);
|
|
|
|
return null;
|
|
};
|
|
|
|
const ActiveMap: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [selectedPHCC, setSelectedPHCC] = useState<string>('');
|
|
const [locations, setLocations] = useState<LocationData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const markersRef = useRef<Record<string, L.Marker>>({});
|
|
|
|
const {
|
|
filterHospital,
|
|
filterSiteName,
|
|
hospitalLocked,
|
|
siteLocked,
|
|
allowedHospitals,
|
|
loading: filtersLoading,
|
|
handleHospitalChange,
|
|
handleSiteChange,
|
|
} = useUserDashboardFilters();
|
|
|
|
const showSiteFilter = isSiteEnabledHospital(filterHospital);
|
|
|
|
const hospitalLocationLinkFilters = useMemo(() => {
|
|
const filters: Record<string, unknown> = { custom_is_hospital: 1 };
|
|
if (allowedHospitals.length > 0) {
|
|
filters.name = ['in', allowedHospitals];
|
|
}
|
|
return filters;
|
|
}, [allowedHospitals]);
|
|
|
|
const mobileTeamSiteFilters = useMemo(
|
|
() => buildMobileTeamSiteFilters(filterHospital, siteLocked ? filterSiteName : undefined),
|
|
[filterHospital, filterSiteName, siteLocked]
|
|
);
|
|
|
|
const phccLocationLinkFilters = useMemo(() => {
|
|
const filters: Record<string, unknown> = { custom_is_phcc: 1 };
|
|
if (siteLocked && filterSiteName) {
|
|
filters.name = filterSiteName;
|
|
}
|
|
return filters;
|
|
}, [siteLocked, filterSiteName]);
|
|
|
|
const applyHospitalSiteFilters = useCallback(
|
|
(
|
|
filters: Record<string, unknown>,
|
|
locationType: 'hospital' | 'phcc',
|
|
siteField: 'site_name' | 'custom_site',
|
|
hospitalName?: string
|
|
) => {
|
|
const hospital = hospitalName || filterHospital;
|
|
if (
|
|
locationType === 'hospital' &&
|
|
filterSiteName &&
|
|
hospital &&
|
|
isSiteEnabledHospital(hospital)
|
|
) {
|
|
filters[siteField] = filterSiteName;
|
|
}
|
|
return filters;
|
|
},
|
|
[filterHospital, filterSiteName]
|
|
);
|
|
|
|
// Fetch location counts based on location type
|
|
const fetchLocationCounts = async (
|
|
location: { name: string; latitude: string | number; longitude: string | number },
|
|
locationType: 'hospital' | 'phcc'
|
|
): Promise<LocationData> => {
|
|
const isPhcc = locationType === 'phcc';
|
|
const assetFilterField = isPhcc ? 'custom_site' : 'company';
|
|
const woFilterField = isPhcc ? 'site_name' : 'company';
|
|
|
|
const counts: Partial<LocationData> = {
|
|
assets: 0,
|
|
normal_work_orders: 0,
|
|
urgent_work_orders: 0,
|
|
planned_maintenance: 0,
|
|
completed_maintenance: 0,
|
|
overdue_maintenance: 0,
|
|
wo_open: 0,
|
|
wo_progress: 0,
|
|
wo_review: 0,
|
|
wo_completed: 0,
|
|
wo_closed: 0,
|
|
phcc_asset_names: []
|
|
};
|
|
|
|
const hospitalName = locationType === 'hospital' ? location.name : undefined;
|
|
|
|
try {
|
|
const assetFilters = applyHospitalSiteFilters(
|
|
{ [assetFilterField]: location.name },
|
|
locationType,
|
|
'custom_site',
|
|
hospitalName
|
|
);
|
|
|
|
// Fetch Asset count
|
|
const assetsResponse = await apiService.apiCall<any>(
|
|
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify(assetFilters))}&fields=["name"]&limit_page_length=0`
|
|
);
|
|
const assetList = assetsResponse?.data || [];
|
|
counts.assets = assetList.length;
|
|
|
|
// Store asset names for PHCC (needed for maintenance log queries)
|
|
if (isPhcc) {
|
|
counts.phcc_asset_names = assetList.map((a: any) => a.name);
|
|
}
|
|
|
|
const normalWOFilters = applyHospitalSiteFilters(
|
|
{
|
|
[woFilterField]: location.name,
|
|
custom_priority_: 'Normal',
|
|
repair_status: ['in', ['Open', 'Work In Progress']],
|
|
},
|
|
locationType,
|
|
'site_name',
|
|
hospitalName
|
|
);
|
|
|
|
// Fetch Normal Work Orders
|
|
const normalWOResponse = await apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(normalWOFilters))}&fields=["name"]`
|
|
);
|
|
counts.normal_work_orders = normalWOResponse?.data?.length || 0;
|
|
|
|
const urgentWOFilters = applyHospitalSiteFilters(
|
|
{
|
|
[woFilterField]: location.name,
|
|
custom_priority_: 'Urgent',
|
|
repair_status: ['in', ['Open', 'Work In Progress']],
|
|
},
|
|
locationType,
|
|
'site_name',
|
|
hospitalName
|
|
);
|
|
|
|
// Fetch Urgent Work Orders
|
|
const urgentWOResponse = await apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(urgentWOFilters))}&fields=["name"]`
|
|
);
|
|
counts.urgent_work_orders = urgentWOResponse?.data?.length || 0;
|
|
|
|
const buildWoStatusFilters = (repairStatus: string) =>
|
|
applyHospitalSiteFilters(
|
|
{
|
|
[woFilterField]: location.name,
|
|
repair_status: repairStatus,
|
|
},
|
|
locationType,
|
|
'site_name',
|
|
hospitalName
|
|
);
|
|
|
|
// Fetch WO Status counts
|
|
const [woOpen, woProgress, woReview, woCompleted, woClosed] = await Promise.all([
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Open')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Work In Progress')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Pending Review')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Completed')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Closed')))}&fields=["name"]`
|
|
),
|
|
]);
|
|
|
|
counts.wo_open = woOpen?.data?.length || 0;
|
|
counts.wo_progress = woProgress?.data?.length || 0;
|
|
counts.wo_review = woReview?.data?.length || 0;
|
|
counts.wo_completed = woCompleted?.data?.length || 0;
|
|
counts.wo_closed = woClosed?.data?.length || 0;
|
|
|
|
// Fetch Maintenance counts - different logic for PHCC vs Hospital
|
|
if (isPhcc && counts.phcc_asset_names && counts.phcc_asset_names.length > 0) {
|
|
// For PHCC, filter by asset_name
|
|
const [plannedPM, completedPM, overduePM] = await Promise.all([
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
|
|
asset_name: ['in', counts.phcc_asset_names],
|
|
maintenance_status: 'Planned'
|
|
}))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
|
|
asset_name: ['in', counts.phcc_asset_names],
|
|
maintenance_status: 'Completed'
|
|
}))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
|
|
asset_name: ['in', counts.phcc_asset_names],
|
|
maintenance_status: 'Overdue'
|
|
}))}&fields=["name"]`
|
|
)
|
|
]);
|
|
counts.planned_maintenance = plannedPM?.data?.length || 0;
|
|
counts.completed_maintenance = completedPM?.data?.length || 0;
|
|
counts.overdue_maintenance = overduePM?.data?.length || 0;
|
|
} else if (!isPhcc) {
|
|
const buildMaintenanceFilters = (maintenanceStatus: string) =>
|
|
applyHospitalSiteFilters(
|
|
{
|
|
custom_hospital_name: location.name,
|
|
maintenance_status: maintenanceStatus,
|
|
},
|
|
locationType,
|
|
'site_name',
|
|
hospitalName
|
|
);
|
|
|
|
// For Hospital, filter by custom_hospital_name
|
|
const [plannedPM, completedPM, overduePM] = await Promise.all([
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Planned')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Completed')))}&fields=["name"]`
|
|
),
|
|
apiService.apiCall<any>(
|
|
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Overdue')))}&fields=["name"]`
|
|
),
|
|
]);
|
|
counts.planned_maintenance = plannedPM?.data?.length || 0;
|
|
counts.completed_maintenance = completedPM?.data?.length || 0;
|
|
counts.overdue_maintenance = overduePM?.data?.length || 0;
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error fetching counts for ${location.name}:`, err);
|
|
}
|
|
|
|
return {
|
|
name: location.name,
|
|
latitude: parseFloat(location.latitude),
|
|
longitude: parseFloat(location.longitude),
|
|
location_type: locationType,
|
|
...counts
|
|
} as LocationData;
|
|
};
|
|
|
|
// Fetch locations and their counts
|
|
const fetchAndRenderData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
let allLocations: LocationData[] = [];
|
|
const fetchPromises: Promise<LocationData[]>[] = [];
|
|
const effectivePhcc = siteLocked && filterSiteName ? filterSiteName : selectedPHCC;
|
|
|
|
// Fetch Hospital locations (if no PHCC is specifically selected, or if hospital is selected)
|
|
if (!effectivePhcc || filterHospital) {
|
|
const hospitalFilters: Record<string, unknown> = {
|
|
latitude: ['!=', ''],
|
|
longitude: ['!=', ''],
|
|
custom_is_hospital: 1,
|
|
};
|
|
|
|
if (filterHospital) {
|
|
hospitalFilters.name = filterHospital;
|
|
} else if (allowedHospitals.length > 0) {
|
|
hospitalFilters.name = ['in', allowedHospitals];
|
|
}
|
|
|
|
fetchPromises.push(
|
|
(async () => {
|
|
const locationsResponse = await apiService.apiCall<any>(
|
|
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(hospitalFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
|
|
);
|
|
const locationList = locationsResponse?.data || [];
|
|
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
|
|
fetchLocationCounts(loc, 'hospital')
|
|
);
|
|
return Promise.all(locationPromises);
|
|
})()
|
|
);
|
|
}
|
|
|
|
// Fetch PHCC locations (if no hospital is specifically selected, or if PHCC is selected)
|
|
if (!filterHospital || effectivePhcc) {
|
|
const phccFilters: Record<string, unknown> = {
|
|
latitude: ['!=', ''],
|
|
longitude: ['!=', ''],
|
|
custom_is_phcc: 1,
|
|
};
|
|
|
|
if (effectivePhcc) {
|
|
phccFilters.name = effectivePhcc;
|
|
}
|
|
|
|
fetchPromises.push(
|
|
(async () => {
|
|
const locationsResponse = await apiService.apiCall<any>(
|
|
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(phccFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
|
|
);
|
|
const locationList = locationsResponse?.data || [];
|
|
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
|
|
fetchLocationCounts(loc, 'phcc')
|
|
);
|
|
return Promise.all(locationPromises);
|
|
})()
|
|
);
|
|
}
|
|
|
|
const results = await Promise.all(fetchPromises);
|
|
allLocations = results.flat().filter((l) => !isNaN(l.latitude) && !isNaN(l.longitude));
|
|
setLocations(allLocations);
|
|
} catch (error) {
|
|
console.error('Error fetching map data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (filtersLoading) return;
|
|
if (siteLocked && filterSiteName) {
|
|
setSelectedPHCC(filterSiteName);
|
|
}
|
|
fetchAndRenderData();
|
|
}, [filterHospital, filterSiteName, selectedPHCC, filtersLoading, siteLocked, allowedHospitals]);
|
|
|
|
// Navigate to list view with filters
|
|
const navigateToWorkOrders = (location: LocationData, priority?: string, status?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (location.location_type === 'phcc') {
|
|
params.set('site_name', location.name);
|
|
} else {
|
|
params.set('company', location.name);
|
|
if (filterSiteName && isSiteEnabledHospital(location.name)) {
|
|
params.set('site_name', filterSiteName);
|
|
}
|
|
}
|
|
if (priority) params.set('priority', priority);
|
|
if (status) params.set('status', status);
|
|
navigate(`/work-orders?${params.toString()}`);
|
|
};
|
|
|
|
const navigateToAssets = (location: LocationData) => {
|
|
const params = new URLSearchParams();
|
|
const filterField = location.location_type === 'phcc' ? 'custom_site' : 'company';
|
|
params.set(filterField, location.name);
|
|
navigate(`/assets?${params.toString()}`);
|
|
};
|
|
|
|
const navigateToMaintenanceCalendar = (location: LocationData, status?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (location.location_type === 'phcc') {
|
|
// For PHCC, we need to pass asset names or use a different approach
|
|
params.set('phcc', location.name);
|
|
} else {
|
|
params.set('hospital', location.name);
|
|
}
|
|
if (status) params.set('status', status);
|
|
navigate(`/maintenance-calendar?${params.toString()}`);
|
|
};
|
|
|
|
// Create popup content with modern UI matching the application
|
|
const createPopupContent = (location: LocationData) => {
|
|
const isPhcc = location.location_type === 'phcc';
|
|
const typeBadge = isPhcc ? (
|
|
<span className="ml-2 px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-semibold rounded-full">
|
|
PHCC
|
|
</span>
|
|
) : (
|
|
<span className="ml-2 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-semibold rounded-full">
|
|
Hospital
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[280px] max-w-[320px]">
|
|
{/* Location Name Header */}
|
|
<div className="mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center flex-wrap">
|
|
{location.name}
|
|
{typeBadge}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
Total Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Work Order Status Section */}
|
|
<div className="mb-4">
|
|
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
|
Work Order Status
|
|
</h4>
|
|
<div className="flex gap-2 mb-3">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, 'Normal')}
|
|
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
|
>
|
|
Normal: {location.normal_work_orders}
|
|
</button>
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, 'Urgent')}
|
|
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
|
>
|
|
Urgent: {location.urgent_work_orders}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status Table */}
|
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
|
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Count</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<tr className="bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
|
<td className="px-3 py-2 text-red-800 dark:text-red-300 font-medium">Open</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, undefined, 'Open')}
|
|
className="text-red-700 dark:text-red-400 font-bold hover:underline cursor-pointer"
|
|
>
|
|
{location.wo_open}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr className="bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
|
|
<td className="px-3 py-2 text-yellow-800 dark:text-yellow-300 font-medium">Work In Progress</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, undefined, 'Work In Progress')}
|
|
className="text-yellow-700 dark:text-yellow-400 font-bold hover:underline cursor-pointer"
|
|
>
|
|
{location.wo_progress}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr className="bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
|
<td className="px-3 py-2 text-blue-800 dark:text-blue-300 font-medium">Pending Review</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, undefined, 'Pending Review')}
|
|
className="text-blue-700 dark:text-blue-400 font-bold hover:underline cursor-pointer"
|
|
>
|
|
{location.wo_review}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr className="bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
|
|
<td className="px-3 py-2 text-green-800 dark:text-green-300 font-medium">Completed</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, undefined, 'Completed')}
|
|
className="text-green-700 dark:text-green-400 font-bold hover:underline cursor-pointer"
|
|
>
|
|
{location.wo_completed}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr className="bg-gray-50 dark:bg-gray-700/40 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors">
|
|
<td className="px-3 py-2 text-gray-800 dark:text-gray-300 font-medium">Closed</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location, undefined, 'Closed')}
|
|
className="text-gray-700 dark:text-gray-300 font-bold hover:underline cursor-pointer"
|
|
>
|
|
{location.wo_closed}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preventive Maintenance Section */}
|
|
<div className="mb-4">
|
|
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
|
Preventive Maintenance
|
|
</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => navigateToMaintenanceCalendar(location, 'Planned')}
|
|
className="px-3 py-1.5 bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
|
>
|
|
Planned: {location.planned_maintenance}
|
|
</button>
|
|
<button
|
|
onClick={() => navigateToMaintenanceCalendar(location, 'Completed')}
|
|
className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
|
>
|
|
Completed: {location.completed_maintenance}
|
|
</button>
|
|
<button
|
|
onClick={() => navigateToMaintenanceCalendar(location, 'Overdue')}
|
|
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
|
>
|
|
Overdue: {location.overdue_maintenance}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={() => navigateToAssets(location)}
|
|
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
|
|
>
|
|
View Assets
|
|
</button>
|
|
<button
|
|
onClick={() => navigateToWorkOrders(location)}
|
|
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 dark:bg-purple-700 dark:hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
|
|
>
|
|
View Work Orders
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
|
|
<div className="flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
<h1 className="text-xl font-semibold text-gray-800 dark:text-white">Active Map</h1>
|
|
</div>
|
|
|
|
{/* Filter Container */}
|
|
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]">
|
|
<div className="flex flex-wrap gap-4 relative z-[1000]">
|
|
{/* Hospital Filter — Location name matches Company/Hospital name */}
|
|
<div className="w-64 relative z-[1000]">
|
|
<LinkField
|
|
label="Hospital"
|
|
doctype="Location"
|
|
value={filterHospital}
|
|
onChange={handleHospitalChange}
|
|
filters={hospitalLocationLinkFilters}
|
|
placeholder="All Hospitals"
|
|
disabled={hospitalLocked}
|
|
/>
|
|
</div>
|
|
|
|
{showSiteFilter && (
|
|
<div className="w-64 relative z-[1000]">
|
|
<LinkField
|
|
label="Site Name"
|
|
doctype="Mobile Team Site"
|
|
value={filterSiteName}
|
|
onChange={handleSiteChange}
|
|
filters={mobileTeamSiteFilters}
|
|
placeholder="All Sites"
|
|
disabled={siteLocked}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* PHCC Filter */}
|
|
<div className="w-64 relative z-[1000]">
|
|
<LinkField
|
|
label="PHCC"
|
|
doctype="Location"
|
|
value={siteLocked && filterSiteName ? filterSiteName : selectedPHCC}
|
|
onChange={setSelectedPHCC}
|
|
filters={phccLocationLinkFilters}
|
|
placeholder="Select PHCC"
|
|
disabled={siteLocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Map Container */}
|
|
<div className="flex-1 relative" style={{ zIndex: 1 }}>
|
|
{loading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 z-[1000]">
|
|
<div className="text-gray-600 dark:text-gray-300">
|
|
{filtersLoading ? 'Loading filters...' : 'Loading map data...'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<MapContainer
|
|
center={[24.8, 45.5]}
|
|
zoom={6}
|
|
style={{ height: '100%', width: '100%' }}
|
|
zoomControl={true}
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
<MapBounds locations={locations} />
|
|
{locations.map((location) => {
|
|
const urgentIndicator = location.urgent_work_orders > 0 ? '🚨 URGENT! ' : '';
|
|
const isPhcc = location.location_type === 'phcc';
|
|
const typeIndicator = isPhcc ? '🏥 PHCC' : '🏨 Hospital';
|
|
const markerKey = `${location.name}-${location.latitude}-${location.longitude}`;
|
|
|
|
return (
|
|
<Marker
|
|
key={markerKey}
|
|
position={[location.latitude, location.longitude]}
|
|
ref={(ref) => {
|
|
if (ref) {
|
|
markersRef.current[markerKey] = ref;
|
|
// Apply marker styling based on location type and urgency
|
|
setTimeout(() => {
|
|
const markerElement = ref.getElement();
|
|
if (markerElement) {
|
|
// Remove all custom classes first
|
|
markerElement.classList.remove('urgent-marker', 'red-marker', 'phcc-marker');
|
|
|
|
if (location.urgent_work_orders > 0) {
|
|
// Same red flashing for both Hospital and PHCC urgent markers
|
|
markerElement.classList.add('urgent-marker', 'red-marker');
|
|
} else if (isPhcc) {
|
|
// Green marker for non-urgent PHCC
|
|
markerElement.classList.add('phcc-marker');
|
|
}
|
|
// Non-urgent hospitals use default blue marker
|
|
}
|
|
}, 100);
|
|
}
|
|
}}
|
|
>
|
|
<Tooltip
|
|
permanent={false}
|
|
direction="right"
|
|
className="hospital-tooltip-modern"
|
|
>
|
|
<div className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[200px]">
|
|
<div className="mb-2 pb-2 border-b border-gray-200 dark:border-gray-700">
|
|
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
|
|
{urgentIndicator}{location.name}
|
|
</h4>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{typeIndicator}
|
|
</p>
|
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
|
Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1 text-xs">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Normal WOs:</span>
|
|
<span className="font-semibold text-blue-700 dark:text-blue-300">{location.normal_work_orders}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Urgent WOs:</span>
|
|
<span className="font-semibold text-red-700 dark:text-red-300">{location.urgent_work_orders}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Planned PMs:</span>
|
|
<span className="font-semibold text-orange-700 dark:text-orange-300">{location.planned_maintenance}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Completed PMs:</span>
|
|
<span className="font-semibold text-green-700 dark:text-green-300">{location.completed_maintenance}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Tooltip>
|
|
<Popup
|
|
className={isPhcc ? "phcc-popup-container" : "hospital-popup-container"}
|
|
maxWidth={320}
|
|
maxHeight={450}
|
|
autoPan={true}
|
|
keepInView={true}
|
|
closeButton={true}
|
|
autoClose={false}
|
|
>
|
|
{createPopupContent(location)}
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
</div>
|
|
|
|
{/* Custom Styles */}
|
|
<style>{`
|
|
/* Ensure filter container and dropdowns stay above map */
|
|
.leaflet-container {
|
|
z-index: 1 !important;
|
|
}
|
|
|
|
/* LinkField dropdown z-index - ensure it's above everything */
|
|
[data-linkfield-dropdown],
|
|
.linkfield-dropdown,
|
|
.react-select__menu,
|
|
.react-select__menu-portal,
|
|
.select2-container,
|
|
.select2-dropdown {
|
|
z-index: 1050 !important;
|
|
}
|
|
|
|
/* Any dropdown menu from LinkField */
|
|
div[role="listbox"],
|
|
ul[role="listbox"],
|
|
.dropdown-menu,
|
|
.autocomplete-dropdown {
|
|
z-index: 1050 !important;
|
|
}
|
|
|
|
.hospital-tooltip-modern {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
.hospital-tooltip-modern .leaflet-tooltip-content-wrapper {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
box-shadow: none !important;
|
|
padding: 0 !important;
|
|
}
|
|
|
|
.hospital-tooltip-modern .leaflet-tooltip-content {
|
|
margin: 0 !important;
|
|
}
|
|
|
|
.hospital-popup-container .leaflet-popup-content-wrapper {
|
|
padding: 0;
|
|
border-radius: 8px;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.hospital-popup-container .leaflet-popup-content {
|
|
margin: 0;
|
|
width: auto !important;
|
|
}
|
|
|
|
/* PHCC Popup Container - with green left border */
|
|
.phcc-popup-container .leaflet-popup-content-wrapper {
|
|
padding: 0;
|
|
border-radius: 8px;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
border-left: 4px solid #28a745;
|
|
}
|
|
|
|
.phcc-popup-container .leaflet-popup-content {
|
|
margin: 0;
|
|
width: auto !important;
|
|
}
|
|
|
|
/* Red Flashing Animation for Urgent Work Orders (Both Hospital & PHCC) */
|
|
/* Fixed: Stays red throughout, just pulses brighter */
|
|
.urgent-marker {
|
|
animation: urgent-flash 2s infinite;
|
|
}
|
|
|
|
@keyframes urgent-flash {
|
|
0%, 50% {
|
|
filter: hue-rotate(120deg) saturate(2) brightness(0.8);
|
|
}
|
|
25%, 75% {
|
|
filter: hue-rotate(120deg) saturate(2.5) brightness(1.5) drop-shadow(0 0 10px red);
|
|
}
|
|
}
|
|
|
|
/* Red marker style (base state for urgent) */
|
|
.red-marker {
|
|
filter: hue-rotate(120deg) saturate(2) brightness(0.8);
|
|
}
|
|
|
|
/* Green marker style for PHCC */
|
|
.phcc-marker {
|
|
filter: hue-rotate(-120deg) saturate(1.3) brightness(1.1);
|
|
}
|
|
|
|
.leaflet-popup {
|
|
z-index: 2000 !important;
|
|
}
|
|
|
|
.leaflet-tooltip {
|
|
z-index: 2000 !important;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ActiveMap; |