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:
poduck
2025-12-07 02:23:00 -05:00
parent 61882b300f
commit 01020861c7
48 changed files with 6156 additions and 148 deletions

View 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;