feat(staff): Restrict staff permissions and add schedule view
- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs - Frontend: Hide management sidebar links from staff members - Add StaffSchedule page with vertical timeline view of appointments - Add StaffHelp page with staff-specific documentation - Return linked_resource_id and can_edit_schedule in user profile for staff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Resource Detail Modal
|
||||
*
|
||||
* Shows resource details including a map of the staff member's
|
||||
* current location when they are en route or in progress.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
|
||||
import { Resource } from '../types';
|
||||
import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation';
|
||||
import Portal from './Portal';
|
||||
import {
|
||||
X,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
Activity,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ResourceDetailModalProps {
|
||||
resource: Resource;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const mapContainerStyle = {
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
|
||||
const defaultCenter = {
|
||||
lat: 39.8283, // Center of US
|
||||
lng: -98.5795,
|
||||
};
|
||||
|
||||
const ResourceDetailModal: React.FC<ResourceDetailModalProps> = ({ resource, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
|
||||
const hasApiKey = googleMapsApiKey.length > 0;
|
||||
|
||||
// Fetch location data
|
||||
const { data: location, isLoading, error } = useResourceLocation(resource.id);
|
||||
|
||||
// Connect to live updates when tracking is active
|
||||
useLiveResourceLocation(resource.id, {
|
||||
enabled: location?.isTracking === true,
|
||||
});
|
||||
|
||||
// Load Google Maps API only if we have a key
|
||||
// When no API key, we skip the hook entirely to avoid warnings
|
||||
const shouldLoadMaps = hasApiKey;
|
||||
const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({
|
||||
googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING',
|
||||
});
|
||||
|
||||
// Treat missing API key as if maps failed to load
|
||||
const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded;
|
||||
const effectiveMapsError = !shouldLoadMaps || mapsLoadError;
|
||||
|
||||
// Map center based on location
|
||||
const mapCenter = useMemo(() => {
|
||||
if (location?.hasLocation && location.latitude && location.longitude) {
|
||||
return { lat: location.latitude, lng: location.longitude };
|
||||
}
|
||||
return defaultCenter;
|
||||
}, [location]);
|
||||
|
||||
// Format timestamp
|
||||
const formattedTimestamp = useMemo(() => {
|
||||
if (!location?.timestamp) return null;
|
||||
const date = new Date(location.timestamp);
|
||||
return date.toLocaleString();
|
||||
}, [location?.timestamp]);
|
||||
|
||||
// Status color based on job status
|
||||
const statusColor = useMemo(() => {
|
||||
if (!location?.activeJob) return 'gray';
|
||||
switch (location.activeJob.status) {
|
||||
case 'EN_ROUTE':
|
||||
return 'yellow';
|
||||
case 'IN_PROGRESS':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}, [location?.activeJob]);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<UserIcon size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{resource.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.staffMember', 'Staff Member')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="sr-only">{t('common.close')}</span>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Active Job Status */}
|
||||
{location?.activeJob && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
statusColor === 'yellow'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: statusColor === 'blue'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity size={20} className={
|
||||
statusColor === 'yellow'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: statusColor === 'blue'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{location.activeJob.statusDisplay}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{location.activeJob.title}
|
||||
</div>
|
||||
</div>
|
||||
{location.isTracking && (
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
{t('resources.liveTracking', 'Live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<MapPin size={16} />
|
||||
{t('resources.currentLocation', 'Current Location')}
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle size={32} className="text-red-400 mx-auto mb-2" />
|
||||
<p className="text-red-600 dark:text-red-400">{t('resources.locationError', 'Failed to load location')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !location?.hasLocation ? (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin size={32} className="text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{location?.message || t('resources.noLocationData', 'No location data available')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{t('resources.locationHint', 'Location will appear when staff is en route')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveMapsError ? (
|
||||
// Fallback when Google Maps isn't available - show coordinates
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg p-6">
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
|
||||
<Navigation size={32} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('resources.gpsCoordinates', 'GPS Coordinates')}
|
||||
</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
{location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)}
|
||||
</p>
|
||||
{location.speed !== undefined && location.speed !== null && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph
|
||||
</p>
|
||||
)}
|
||||
{location.heading !== undefined && location.heading !== null && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}°
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${location.latitude},${location.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
{t('resources.openInMaps', 'Open in Google Maps')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveMapsLoaded ? (
|
||||
<GoogleMap
|
||||
mapContainerStyle={mapContainerStyle}
|
||||
center={mapCenter}
|
||||
zoom={15}
|
||||
options={{
|
||||
disableDefaultUI: false,
|
||||
zoomControl: true,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true,
|
||||
}}
|
||||
>
|
||||
{location.latitude && location.longitude && (
|
||||
<Marker
|
||||
position={{ lat: location.latitude, lng: location.longitude }}
|
||||
title={resource.name}
|
||||
icon={{
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 10,
|
||||
fillColor: location.isTracking ? '#22c55e' : '#3b82f6',
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GoogleMap>
|
||||
) : (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location Details */}
|
||||
{location?.hasLocation && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{t('resources.lastUpdate', 'Last Update')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formattedTimestamp || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{location.accuracy && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.accuracy', 'Accuracy')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{location.accuracy < 1000
|
||||
? `${location.accuracy.toFixed(0)}m`
|
||||
: `${(location.accuracy / 1000).toFixed(1)}km`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{location.speed !== undefined && location.speed !== null && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.speed', 'Speed')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{(location.speed * 2.237).toFixed(1)} mph
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{location.heading !== undefined && location.heading !== null && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.heading', 'Heading')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{location.heading.toFixed(0)}°
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceDetailModal;
|
||||
Reference in New Issue
Block a user