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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user