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:
186
frontend/src/hooks/useResourceLocation.ts
Normal file
186
frontend/src/hooks/useResourceLocation.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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] }),
|
||||
};
|
||||
};
|
||||
@@ -30,6 +30,7 @@ export const useResources = (filters?: ResourceFilters) => {
|
||||
userId: r.user_id ? String(r.user_id) : undefined,
|
||||
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
||||
savedLaneCount: r.saved_lane_count,
|
||||
userCanEditSchedule: r.user_can_edit_schedule ?? false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -51,6 +52,7 @@ export const useResource = (id: string) => {
|
||||
userId: data.user_id ? String(data.user_id) : undefined,
|
||||
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||
savedLaneCount: data.saved_lane_count,
|
||||
userCanEditSchedule: data.user_can_edit_schedule ?? false,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -65,13 +67,23 @@ export const useCreateResource = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
|
||||
const backendData = {
|
||||
const backendData: any = {
|
||||
name: resourceData.name,
|
||||
type: resourceData.type,
|
||||
user: resourceData.userId ? parseInt(resourceData.userId) : null,
|
||||
timezone: 'UTC', // Default timezone
|
||||
};
|
||||
|
||||
if (resourceData.maxConcurrentEvents !== undefined) {
|
||||
backendData.max_concurrent_events = resourceData.maxConcurrentEvents;
|
||||
}
|
||||
if (resourceData.savedLaneCount !== undefined) {
|
||||
backendData.saved_lane_count = resourceData.savedLaneCount;
|
||||
}
|
||||
if (resourceData.userCanEditSchedule !== undefined) {
|
||||
backendData.user_can_edit_schedule = resourceData.userCanEditSchedule;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post('/resources/', backendData);
|
||||
return data;
|
||||
},
|
||||
@@ -101,6 +113,9 @@ export const useUpdateResource = () => {
|
||||
if (updates.savedLaneCount !== undefined) {
|
||||
backendData.saved_lane_count = updates.savedLaneCount;
|
||||
}
|
||||
if (updates.userCanEditSchedule !== undefined) {
|
||||
backendData.user_can_edit_schedule = updates.userCanEditSchedule;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user