- 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>
187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
/**
|
|
* Resource Location Hook
|
|
*
|
|
* Fetches the latest location for a resource's linked staff member.
|
|
* Used for tracking staff during en route jobs.
|
|
*/
|
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import apiClient from '../api/client';
|
|
|
|
export interface ResourceLocation {
|
|
hasLocation: boolean;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
accuracy?: number;
|
|
heading?: number;
|
|
speed?: number;
|
|
timestamp?: string;
|
|
isTracking: boolean;
|
|
activeJob?: {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
statusDisplay: string;
|
|
} | null;
|
|
message?: string;
|
|
}
|
|
|
|
interface BackendLocationResponse {
|
|
has_location: boolean;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
accuracy?: number;
|
|
heading?: number;
|
|
speed?: number;
|
|
timestamp?: string;
|
|
is_tracking?: boolean;
|
|
active_job?: {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
status_display: string;
|
|
} | null;
|
|
message?: string;
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch a resource's latest location
|
|
*/
|
|
export const useResourceLocation = (resourceId: string | null, options?: { enabled?: boolean }) => {
|
|
return useQuery<ResourceLocation>({
|
|
queryKey: ['resourceLocation', resourceId],
|
|
queryFn: async () => {
|
|
const { data } = await apiClient.get<BackendLocationResponse>(`/resources/${resourceId}/location/`);
|
|
|
|
return {
|
|
hasLocation: data.has_location,
|
|
latitude: data.latitude,
|
|
longitude: data.longitude,
|
|
accuracy: data.accuracy,
|
|
heading: data.heading,
|
|
speed: data.speed,
|
|
timestamp: data.timestamp,
|
|
isTracking: data.is_tracking ?? false,
|
|
activeJob: data.active_job ? {
|
|
id: data.active_job.id,
|
|
title: data.active_job.title,
|
|
status: data.active_job.status,
|
|
statusDisplay: data.active_job.status_display,
|
|
} : null,
|
|
message: data.message,
|
|
};
|
|
},
|
|
enabled: !!resourceId && (options?.enabled !== false),
|
|
refetchInterval: false, // We'll use WebSocket for live updates instead
|
|
staleTime: 30000, // Consider data stale after 30 seconds
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook for live location updates via WebSocket
|
|
*
|
|
* Connects to WebSocket when enabled and updates the query cache
|
|
* when new location data arrives.
|
|
*/
|
|
export const useLiveResourceLocation = (
|
|
resourceId: string | null,
|
|
options?: { enabled?: boolean }
|
|
) => {
|
|
const queryClient = useQueryClient();
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const connect = useCallback(() => {
|
|
if (!resourceId || options?.enabled === false) return;
|
|
|
|
// Get WebSocket URL from current host
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const host = window.location.hostname;
|
|
const port = '8000'; // Backend port
|
|
const wsUrl = `${protocol}//${host}:${port}/ws/resource-location/${resourceId}/`;
|
|
|
|
try {
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
console.log('[useLiveResourceLocation] WebSocket connected for resource:', resourceId);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
if (data.type === 'location_update') {
|
|
// Update the query cache with new location data
|
|
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
|
|
...old,
|
|
hasLocation: true,
|
|
latitude: data.latitude,
|
|
longitude: data.longitude,
|
|
accuracy: data.accuracy,
|
|
heading: data.heading,
|
|
speed: data.speed,
|
|
timestamp: data.timestamp,
|
|
isTracking: true,
|
|
activeJob: data.active_job ? {
|
|
id: data.active_job.id,
|
|
title: data.active_job.title,
|
|
status: data.active_job.status,
|
|
statusDisplay: data.active_job.status_display,
|
|
} : old?.activeJob ?? null,
|
|
}));
|
|
} else if (data.type === 'tracking_stopped') {
|
|
// Staff stopped tracking
|
|
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
|
|
...old,
|
|
hasLocation: old?.hasLocation ?? false,
|
|
isTracking: false,
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
console.error('[useLiveResourceLocation] Failed to parse message:', err);
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('[useLiveResourceLocation] WebSocket error:', error);
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
console.log('[useLiveResourceLocation] WebSocket closed:', event.code, event.reason);
|
|
wsRef.current = null;
|
|
|
|
// Reconnect after 5 seconds if not a clean close
|
|
if (event.code !== 1000 && options?.enabled !== false) {
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
connect();
|
|
}, 5000);
|
|
}
|
|
};
|
|
} catch (err) {
|
|
console.error('[useLiveResourceLocation] Failed to connect:', err);
|
|
}
|
|
}, [resourceId, options?.enabled, queryClient]);
|
|
|
|
useEffect(() => {
|
|
connect();
|
|
|
|
return () => {
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.close(1000, 'Component unmounting');
|
|
wsRef.current = null;
|
|
}
|
|
};
|
|
}, [connect]);
|
|
|
|
// Return a function to manually refresh
|
|
return {
|
|
refresh: () => queryClient.invalidateQueries({ queryKey: ['resourceLocation', resourceId] }),
|
|
};
|
|
};
|