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,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] }),
};
};

View File

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