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

@@ -2,6 +2,7 @@
* Jobs List Screen
*
* Displays jobs in a timeline view with day/week toggle.
* Supports drag-and-drop rescheduling and resize if user has permission.
*/
import { useCallback, useState, useRef, useMemo } from 'react';
@@ -16,12 +17,15 @@ import {
Alert,
Dimensions,
} from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQueryClient } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { getJobColor, jobStatusColors } from '../../src/api/jobs';
import { getJobColor, updateAppointmentTime } from '../../src/api/jobs';
import { useAuth } from '../../src/hooks/useAuth';
import { useJobs } from '../../src/hooks/useJobs';
import { DraggableJobBlock } from '../../src/components/DraggableJobBlock';
import type { JobListItem, JobStatus } from '../../src/types';
const HOUR_HEIGHT = 60;
@@ -149,91 +153,8 @@ function getWeekDays(baseDate: Date): Date[] {
return days;
}
function JobBlock({
job,
onPress,
viewMode,
dayIndex = 0,
laneIndex = 0,
totalLanes = 1,
}: {
job: JobListItem;
onPress: () => void;
viewMode: ViewMode;
dayIndex?: number;
laneIndex?: number;
totalLanes?: number;
}) {
// Use time-aware color function (shows red for overdue, yellow for in-progress window, etc.)
const statusColor = getJobColor(job);
const startDate = new Date(job.start_time);
const endDate = new Date(job.end_time);
const startHour = startDate.getHours() + startDate.getMinutes() / 60;
const endHour = endDate.getHours() + endDate.getMinutes() / 60;
const top = startHour * HOUR_HEIGHT;
const height = Math.max((endHour - startHour) * HOUR_HEIGHT, 40);
// Calculate width and position based on lanes
let blockStyle: { left: number; width: number };
if (viewMode === 'week') {
// Week view: divide the day column by lanes
const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes;
blockStyle = {
left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth,
width: laneWidth - 2,
};
} else {
// Day view: divide the full width by lanes
const laneWidth = DAY_VIEW_WIDTH / totalLanes;
blockStyle = {
left: laneIndex * laneWidth,
width: laneWidth - 4,
};
}
return (
<TouchableOpacity
style={[
styles.jobBlock,
{
top,
height,
borderLeftColor: statusColor,
...blockStyle,
},
]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.jobBlockHeader}>
<Text style={[styles.jobBlockTime, viewMode === 'week' && styles.jobBlockTimeSmall]}>
{formatTime(job.start_time)}
</Text>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
<Text
style={[styles.jobBlockTitle, viewMode === 'week' && styles.jobBlockTitleSmall]}
numberOfLines={viewMode === 'week' ? 1 : 2}
>
{job.title}
</Text>
{viewMode === 'day' && job.customer_name && (
<Text style={styles.jobBlockCustomer} numberOfLines={1}>
{job.customer_name}
</Text>
)}
{viewMode === 'day' && job.address && (
<Text style={styles.jobBlockAddress} numberOfLines={1}>
{job.address}
</Text>
)}
</TouchableOpacity>
);
}
// Note: JobBlock functionality moved to DraggableJobBlock component
// which supports drag-and-drop rescheduling and resize
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
const hours = [];
@@ -302,12 +223,16 @@ function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
export default function JobsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient();
const { user, logout } = useAuth();
const [refreshing, setRefreshing] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [selectedDate, setSelectedDate] = useState(new Date());
const scrollRef = useRef<ScrollView>(null);
// Check if user can edit schedule
const canEditSchedule = user?.can_edit_schedule ?? false;
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
// Calculate the date range to fetch based on view mode
@@ -374,6 +299,24 @@ export default function JobsScreen() {
);
};
// Handle drag-and-drop time changes
const handleTimeChange = useCallback(async (
jobId: number,
newStartTime: Date,
newEndTime: Date
) => {
try {
await updateAppointmentTime(jobId, {
start_time: newStartTime.toISOString(),
end_time: newEndTime.toISOString(),
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['jobs'] });
} catch (error: any) {
throw new Error(error.response?.data?.detail || 'Failed to update appointment');
}
}, [queryClient]);
// Scroll to current time on mount
const scrollToNow = useCallback(() => {
const now = new Date();
@@ -409,23 +352,25 @@ export default function JobsScreen() {
const jobsByDay = useMemo(() => {
if (viewMode !== 'week') return {};
const grouped: Record<number, JobWithLane[]> = {};
// First group raw jobs by day
const tempGrouped: Record<number, JobListItem[]> = {};
weekDays.forEach((_, index) => {
grouped[index] = [];
tempGrouped[index] = [];
});
filteredJobs.forEach(job => {
const jobDate = new Date(job.start_time);
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
if (dayIndex !== -1) {
grouped[dayIndex].push(job);
tempGrouped[dayIndex].push(job);
}
});
// Calculate lane layout for each day
Object.keys(grouped).forEach(key => {
// Then calculate lane layout for each day
const grouped: Record<number, JobWithLane[]> = {};
Object.keys(tempGrouped).forEach(key => {
const dayIndex = parseInt(key);
grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]);
grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]);
});
return grouped;
@@ -459,6 +404,7 @@ export default function JobsScreen() {
});
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<View>
@@ -577,10 +523,12 @@ export default function JobsScreen() {
]}>
{viewMode === 'day' ? (
dayJobsWithLanes.map((job) => (
<JobBlock
<DraggableJobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
onTimeChange={handleTimeChange}
canEdit={canEditSchedule}
viewMode={viewMode}
laneIndex={job.laneIndex}
totalLanes={job.totalLanes}
@@ -589,10 +537,12 @@ export default function JobsScreen() {
) : (
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
jobs.map((job) => (
<JobBlock
<DraggableJobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
onTimeChange={handleTimeChange}
canEdit={false}
viewMode={viewMode}
dayIndex={parseInt(dayIndex)}
laneIndex={job.laneIndex}
@@ -616,6 +566,7 @@ export default function JobsScreen() {
)}
</ScrollView>
</View>
</GestureHandlerRootView>
);
}