- 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>
876 lines
23 KiB
TypeScript
876 lines
23 KiB
TypeScript
/**
|
|
* 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';
|
|
import {
|
|
View,
|
|
Text,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
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, 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;
|
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7; // For week view
|
|
const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70; // Width available for job blocks in day view
|
|
|
|
type ViewMode = 'day' | 'week';
|
|
|
|
// Lane layout calculation for overlapping appointments
|
|
interface JobWithLane extends JobListItem {
|
|
laneIndex: number;
|
|
totalLanes: number;
|
|
}
|
|
|
|
function calculateLaneLayout(jobs: JobListItem[]): JobWithLane[] {
|
|
if (jobs.length === 0) return [];
|
|
|
|
// Sort by start time
|
|
const sortedJobs = [...jobs].sort((a, b) =>
|
|
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
|
);
|
|
|
|
// Find overlapping groups and assign lanes
|
|
const result: JobWithLane[] = [];
|
|
const laneEndTimes: number[] = [];
|
|
|
|
// First pass: assign lane indices
|
|
const jobsWithLanes = sortedJobs.map((job) => {
|
|
const startTime = new Date(job.start_time).getTime();
|
|
const endTime = new Date(job.end_time).getTime();
|
|
|
|
// Find the first available lane
|
|
let laneIndex = 0;
|
|
while (laneIndex < laneEndTimes.length && laneEndTimes[laneIndex] > startTime) {
|
|
laneIndex++;
|
|
}
|
|
|
|
// Assign end time to this lane
|
|
laneEndTimes[laneIndex] = endTime;
|
|
|
|
return { ...job, laneIndex, totalLanes: 1 };
|
|
});
|
|
|
|
// Second pass: find overlapping groups and set totalLanes
|
|
// Group overlapping jobs together
|
|
const groups: JobWithLane[][] = [];
|
|
let currentGroup: JobWithLane[] = [];
|
|
let groupEndTime = 0;
|
|
|
|
jobsWithLanes.forEach((job) => {
|
|
const startTime = new Date(job.start_time).getTime();
|
|
const endTime = new Date(job.end_time).getTime();
|
|
|
|
if (currentGroup.length === 0 || startTime < groupEndTime) {
|
|
// Job overlaps with current group
|
|
currentGroup.push(job);
|
|
groupEndTime = Math.max(groupEndTime, endTime);
|
|
} else {
|
|
// Start a new group
|
|
if (currentGroup.length > 0) {
|
|
groups.push(currentGroup);
|
|
}
|
|
currentGroup = [job];
|
|
groupEndTime = endTime;
|
|
}
|
|
});
|
|
|
|
if (currentGroup.length > 0) {
|
|
groups.push(currentGroup);
|
|
}
|
|
|
|
// Set totalLanes for each job in its group
|
|
groups.forEach((group) => {
|
|
const maxLane = Math.max(...group.map(j => j.laneIndex)) + 1;
|
|
group.forEach((job) => {
|
|
job.totalLanes = maxLane;
|
|
result.push(job);
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function formatTime(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
}
|
|
|
|
function getHourLabel(hour: number): string {
|
|
if (hour === 0) return '12 AM';
|
|
if (hour === 12) return '12 PM';
|
|
if (hour < 12) return `${hour} AM`;
|
|
return `${hour - 12} PM`;
|
|
}
|
|
|
|
function isSameDay(date1: Date, date2: Date): boolean {
|
|
// Compare using local date parts
|
|
return (
|
|
date1.getFullYear() === date2.getFullYear() &&
|
|
date1.getMonth() === date2.getMonth() &&
|
|
date1.getDate() === date2.getDate()
|
|
);
|
|
}
|
|
|
|
function parseJobDate(dateString: string): Date {
|
|
// Parse ISO date string and return a Date object
|
|
return new Date(dateString);
|
|
}
|
|
|
|
function getWeekDays(baseDate: Date): Date[] {
|
|
const days: Date[] = [];
|
|
const startOfWeek = new Date(baseDate);
|
|
startOfWeek.setDate(baseDate.getDate() - baseDate.getDay()); // Start on Sunday
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const day = new Date(startOfWeek);
|
|
day.setDate(startOfWeek.getDate() + i);
|
|
days.push(day);
|
|
}
|
|
return days;
|
|
}
|
|
|
|
// Note: JobBlock functionality moved to DraggableJobBlock component
|
|
// which supports drag-and-drop rescheduling and resize
|
|
|
|
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
|
|
const hours = [];
|
|
for (let hour = 0; hour < 24; hour++) {
|
|
hours.push(hour);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.timelineGrid}>
|
|
{hours.map((hour) => (
|
|
<View key={hour} style={styles.hourRow}>
|
|
<Text style={styles.hourLabel}>{getHourLabel(hour)}</Text>
|
|
<View style={styles.hourLine} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function WeekHeader({ weekDays, selectedDate }: { weekDays: Date[]; selectedDate: Date }) {
|
|
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
const today = new Date();
|
|
|
|
return (
|
|
<View style={styles.weekHeader}>
|
|
<View style={styles.weekHeaderSpacer} />
|
|
{weekDays.map((day, index) => {
|
|
const isToday = isSameDay(day, today);
|
|
const isSelected = isSameDay(day, selectedDate);
|
|
return (
|
|
<View key={index} style={styles.weekDayHeader}>
|
|
<Text style={[styles.weekDayName, isToday && styles.weekDayNameToday]}>
|
|
{dayNames[index]}
|
|
</Text>
|
|
<View style={[
|
|
styles.weekDayNumber,
|
|
isToday && styles.weekDayNumberToday,
|
|
]}>
|
|
<Text style={[
|
|
styles.weekDayNumberText,
|
|
isToday && styles.weekDayNumberTextToday,
|
|
]}>
|
|
{day.getDate()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
|
|
const now = new Date();
|
|
const currentHour = now.getHours() + now.getMinutes() / 60;
|
|
const top = currentHour * HOUR_HEIGHT;
|
|
|
|
return (
|
|
<View style={[styles.currentTimeLine, { top }]}>
|
|
<View style={styles.currentTimeDot} />
|
|
<View style={styles.currentTimeLineBar} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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
|
|
const jobsQueryParams = useMemo(() => {
|
|
if (viewMode === 'day') {
|
|
// For day view, fetch just that day
|
|
const startOfDay = new Date(selectedDate);
|
|
startOfDay.setHours(0, 0, 0, 0);
|
|
const endOfDay = new Date(selectedDate);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
|
|
return {
|
|
startDate: startOfDay.toISOString(),
|
|
endDate: endOfDay.toISOString(),
|
|
};
|
|
} else {
|
|
// For week view, fetch the entire week
|
|
const weekStart = new Date(weekDays[0]);
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
const weekEnd = new Date(weekDays[6]);
|
|
weekEnd.setHours(23, 59, 59, 999);
|
|
|
|
return {
|
|
startDate: weekStart.toISOString(),
|
|
endDate: weekEnd.toISOString(),
|
|
};
|
|
}
|
|
}, [selectedDate, viewMode, weekDays]);
|
|
|
|
// Use the jobs hook with WebSocket integration for real-time updates
|
|
const {
|
|
data,
|
|
isLoading,
|
|
error,
|
|
refresh,
|
|
isConnected,
|
|
} = useJobs(jobsQueryParams);
|
|
|
|
const onRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
await refresh();
|
|
setRefreshing(false);
|
|
}, [refresh]);
|
|
|
|
const handleJobPress = (jobId: number) => {
|
|
router.push(`/(auth)/job/${jobId}`);
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
Alert.alert(
|
|
'Logout',
|
|
'Are you sure you want to logout?',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Logout',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
await logout();
|
|
router.replace('/login');
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
// 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();
|
|
const currentHour = now.getHours();
|
|
const scrollPosition = Math.max(0, (currentHour - 1) * HOUR_HEIGHT);
|
|
scrollRef.current?.scrollTo({ y: scrollPosition, animated: false });
|
|
}, []);
|
|
|
|
// Filter jobs based on view mode
|
|
const filteredJobs = useMemo(() => {
|
|
const jobs = data?.jobs || [];
|
|
if (viewMode === 'day') {
|
|
return jobs.filter(job => isSameDay(new Date(job.start_time), selectedDate));
|
|
}
|
|
// Week view - return all jobs for the week
|
|
const weekStart = weekDays[0];
|
|
const weekEnd = new Date(weekDays[6]);
|
|
weekEnd.setHours(23, 59, 59, 999);
|
|
|
|
return jobs.filter(job => {
|
|
const jobDate = new Date(job.start_time);
|
|
return jobDate >= weekStart && jobDate <= weekEnd;
|
|
});
|
|
}, [data?.jobs, viewMode, selectedDate, weekDays]);
|
|
|
|
// Calculate lane layout for day view (overlapping appointments)
|
|
const dayJobsWithLanes = useMemo(() => {
|
|
if (viewMode !== 'day') return [];
|
|
return calculateLaneLayout(filteredJobs);
|
|
}, [filteredJobs, viewMode]);
|
|
|
|
// Group jobs by day for week view with lane layout
|
|
const jobsByDay = useMemo(() => {
|
|
if (viewMode !== 'week') return {};
|
|
|
|
// First group raw jobs by day
|
|
const tempGrouped: Record<number, JobListItem[]> = {};
|
|
weekDays.forEach((_, index) => {
|
|
tempGrouped[index] = [];
|
|
});
|
|
|
|
filteredJobs.forEach(job => {
|
|
const jobDate = new Date(job.start_time);
|
|
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
|
|
if (dayIndex !== -1) {
|
|
tempGrouped[dayIndex].push(job);
|
|
}
|
|
});
|
|
|
|
// Then calculate lane layout for each day
|
|
const grouped: Record<number, JobWithLane[]> = {};
|
|
Object.keys(tempGrouped).forEach(key => {
|
|
const dayIndex = parseInt(key);
|
|
grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]);
|
|
});
|
|
|
|
return grouped;
|
|
}, [filteredJobs, viewMode, weekDays]);
|
|
|
|
if (isLoading && !refreshing) {
|
|
return (
|
|
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
|
|
<ActivityIndicator size="large" color="#2563eb" />
|
|
<Text style={styles.loadingText}>Loading jobs...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
|
|
<Ionicons name="alert-circle-outline" size={48} color="#ef4444" />
|
|
<Text style={styles.errorText}>Failed to load jobs</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
|
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const today = selectedDate.toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
|
|
return (
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<View style={styles.header}>
|
|
<View>
|
|
<Text style={styles.greeting}>Hello, {user?.name || 'there'}!</Text>
|
|
<Text style={styles.dateText}>{today}</Text>
|
|
</View>
|
|
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
|
|
<Ionicons name="log-out-outline" size={24} color="#fff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* View Mode Toggle */}
|
|
{/* Date Navigation */}
|
|
<View style={styles.dateNavContainer}>
|
|
<TouchableOpacity
|
|
style={styles.navButton}
|
|
onPress={() => {
|
|
const newDate = new Date(selectedDate);
|
|
if (viewMode === 'day') {
|
|
newDate.setDate(newDate.getDate() - 1);
|
|
} else {
|
|
newDate.setDate(newDate.getDate() - 7);
|
|
}
|
|
setSelectedDate(newDate);
|
|
}}
|
|
>
|
|
<Ionicons name="chevron-back" size={24} color="#2563eb" />
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.todayButton}
|
|
onPress={() => setSelectedDate(new Date())}
|
|
>
|
|
<Text style={styles.todayButtonText}>Today</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.navButton}
|
|
onPress={() => {
|
|
const newDate = new Date(selectedDate);
|
|
if (viewMode === 'day') {
|
|
newDate.setDate(newDate.getDate() + 1);
|
|
} else {
|
|
newDate.setDate(newDate.getDate() + 7);
|
|
}
|
|
setSelectedDate(newDate);
|
|
}}
|
|
>
|
|
<Ionicons name="chevron-forward" size={24} color="#2563eb" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.viewModeContainer}>
|
|
<View style={styles.viewModeToggle}>
|
|
<TouchableOpacity
|
|
style={[styles.viewModeButton, viewMode === 'day' && styles.viewModeButtonActive]}
|
|
onPress={() => setViewMode('day')}
|
|
>
|
|
<Ionicons
|
|
name="today-outline"
|
|
size={18}
|
|
color={viewMode === 'day' ? '#fff' : '#6b7280'}
|
|
/>
|
|
<Text style={[styles.viewModeText, viewMode === 'day' && styles.viewModeTextActive]}>
|
|
Day
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.viewModeButton, viewMode === 'week' && styles.viewModeButtonActive]}
|
|
onPress={() => setViewMode('week')}
|
|
>
|
|
<Ionicons
|
|
name="calendar-outline"
|
|
size={18}
|
|
color={viewMode === 'week' ? '#fff' : '#6b7280'}
|
|
/>
|
|
<Text style={[styles.viewModeText, viewMode === 'week' && styles.viewModeTextActive]}>
|
|
Week
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={styles.jobsCount}>
|
|
{filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Week Header (only in week view) */}
|
|
{viewMode === 'week' && (
|
|
<WeekHeader weekDays={weekDays} selectedDate={selectedDate} />
|
|
)}
|
|
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
style={styles.timeline}
|
|
contentContainerStyle={styles.timelineContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={['#2563eb']}
|
|
tintColor="#2563eb"
|
|
/>
|
|
}
|
|
onLayout={scrollToNow}
|
|
showsVerticalScrollIndicator={true}
|
|
>
|
|
<TimelineGrid viewMode={viewMode} />
|
|
|
|
{viewMode === 'day' && isSameDay(selectedDate, new Date()) && (
|
|
<CurrentTimeLine viewMode={viewMode} />
|
|
)}
|
|
|
|
<View style={[
|
|
styles.jobsContainer,
|
|
viewMode === 'week' && styles.jobsContainerWeek,
|
|
]}>
|
|
{viewMode === 'day' ? (
|
|
dayJobsWithLanes.map((job) => (
|
|
<DraggableJobBlock
|
|
key={job.id}
|
|
job={job}
|
|
onPress={() => handleJobPress(job.id)}
|
|
onTimeChange={handleTimeChange}
|
|
canEdit={canEditSchedule}
|
|
viewMode={viewMode}
|
|
laneIndex={job.laneIndex}
|
|
totalLanes={job.totalLanes}
|
|
/>
|
|
))
|
|
) : (
|
|
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
|
|
jobs.map((job) => (
|
|
<DraggableJobBlock
|
|
key={job.id}
|
|
job={job}
|
|
onPress={() => handleJobPress(job.id)}
|
|
onTimeChange={handleTimeChange}
|
|
canEdit={false}
|
|
viewMode={viewMode}
|
|
dayIndex={parseInt(dayIndex)}
|
|
laneIndex={job.laneIndex}
|
|
totalLanes={job.totalLanes}
|
|
/>
|
|
))
|
|
)
|
|
)}
|
|
</View>
|
|
|
|
{filteredJobs.length === 0 && (
|
|
<View style={styles.emptyOverlay}>
|
|
<View style={styles.emptyContainer}>
|
|
<Ionicons name="calendar-outline" size={48} color="#d1d5db" />
|
|
<Text style={styles.emptyTitle}>No Jobs Scheduled</Text>
|
|
<Text style={styles.emptyText}>
|
|
Pull down to refresh
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#f9fafb',
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
backgroundColor: '#2563eb',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 16,
|
|
},
|
|
greeting: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
color: '#fff',
|
|
},
|
|
dateText: {
|
|
fontSize: 14,
|
|
color: 'rgba(255,255,255,0.8)',
|
|
marginTop: 2,
|
|
},
|
|
logoutButton: {
|
|
padding: 8,
|
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
borderRadius: 8,
|
|
},
|
|
dateNavContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: '#fff',
|
|
paddingVertical: 8,
|
|
gap: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#e5e7eb',
|
|
},
|
|
navButton: {
|
|
padding: 8,
|
|
borderRadius: 8,
|
|
backgroundColor: '#f3f4f6',
|
|
},
|
|
todayButton: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderRadius: 8,
|
|
backgroundColor: '#eff6ff',
|
|
},
|
|
todayButtonText: {
|
|
color: '#2563eb',
|
|
fontWeight: '600',
|
|
fontSize: 14,
|
|
},
|
|
viewModeContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
backgroundColor: '#fff',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#e5e7eb',
|
|
},
|
|
viewModeToggle: {
|
|
flexDirection: 'row',
|
|
backgroundColor: '#f3f4f6',
|
|
borderRadius: 8,
|
|
padding: 2,
|
|
},
|
|
viewModeButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 6,
|
|
gap: 4,
|
|
},
|
|
viewModeButtonActive: {
|
|
backgroundColor: '#2563eb',
|
|
},
|
|
viewModeText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: '#6b7280',
|
|
},
|
|
viewModeTextActive: {
|
|
color: '#fff',
|
|
},
|
|
jobsCount: {
|
|
fontSize: 14,
|
|
color: '#6b7280',
|
|
},
|
|
weekHeader: {
|
|
flexDirection: 'row',
|
|
backgroundColor: '#fff',
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#e5e7eb',
|
|
paddingVertical: 8,
|
|
},
|
|
weekHeaderSpacer: {
|
|
width: 50,
|
|
},
|
|
weekDayHeader: {
|
|
width: DAY_COLUMN_WIDTH,
|
|
alignItems: 'center',
|
|
},
|
|
weekDayName: {
|
|
fontSize: 11,
|
|
color: '#9ca3af',
|
|
fontWeight: '500',
|
|
},
|
|
weekDayNameToday: {
|
|
color: '#2563eb',
|
|
},
|
|
weekDayNumber: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginTop: 2,
|
|
},
|
|
weekDayNumberToday: {
|
|
backgroundColor: '#2563eb',
|
|
},
|
|
weekDayNumberText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#374151',
|
|
},
|
|
weekDayNumberTextToday: {
|
|
color: '#fff',
|
|
},
|
|
timeline: {
|
|
flex: 1,
|
|
},
|
|
timelineContent: {
|
|
position: 'relative',
|
|
minHeight: 24 * HOUR_HEIGHT,
|
|
paddingBottom: 40,
|
|
},
|
|
timelineGrid: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
},
|
|
hourRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
height: HOUR_HEIGHT,
|
|
},
|
|
hourLabel: {
|
|
width: 50,
|
|
fontSize: 11,
|
|
color: '#9ca3af',
|
|
textAlign: 'right',
|
|
paddingRight: 8,
|
|
paddingTop: 2,
|
|
},
|
|
hourLine: {
|
|
flex: 1,
|
|
height: 1,
|
|
backgroundColor: '#e5e7eb',
|
|
marginTop: 8,
|
|
},
|
|
currentTimeLine: {
|
|
position: 'absolute',
|
|
left: 42,
|
|
right: 0,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
zIndex: 10,
|
|
},
|
|
currentTimeDot: {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: '#ef4444',
|
|
},
|
|
currentTimeLineBar: {
|
|
flex: 1,
|
|
height: 2,
|
|
backgroundColor: '#ef4444',
|
|
},
|
|
jobsContainer: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 58,
|
|
right: 12,
|
|
},
|
|
jobsContainerWeek: {
|
|
left: 50,
|
|
right: 0,
|
|
},
|
|
jobBlock: {
|
|
position: 'absolute',
|
|
backgroundColor: '#fff',
|
|
borderRadius: 6,
|
|
borderLeftWidth: 3,
|
|
padding: 6,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 2,
|
|
elevation: 2,
|
|
overflow: 'hidden',
|
|
},
|
|
jobBlockHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 2,
|
|
},
|
|
jobBlockTime: {
|
|
fontSize: 10,
|
|
color: '#6b7280',
|
|
fontWeight: '500',
|
|
},
|
|
jobBlockTimeSmall: {
|
|
fontSize: 9,
|
|
},
|
|
statusDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
},
|
|
jobBlockTitle: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
color: '#111827',
|
|
marginBottom: 2,
|
|
},
|
|
jobBlockTitleSmall: {
|
|
fontSize: 10,
|
|
},
|
|
jobBlockCustomer: {
|
|
fontSize: 11,
|
|
color: '#4b5563',
|
|
},
|
|
jobBlockAddress: {
|
|
fontSize: 10,
|
|
color: '#9ca3af',
|
|
marginTop: 2,
|
|
},
|
|
centerContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 24,
|
|
backgroundColor: '#f9fafb',
|
|
},
|
|
loadingText: {
|
|
marginTop: 12,
|
|
fontSize: 16,
|
|
color: '#6b7280',
|
|
},
|
|
errorText: {
|
|
marginTop: 12,
|
|
fontSize: 16,
|
|
color: '#ef4444',
|
|
textAlign: 'center',
|
|
},
|
|
retryButton: {
|
|
marginTop: 16,
|
|
backgroundColor: '#2563eb',
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 12,
|
|
borderRadius: 8,
|
|
},
|
|
retryButtonText: {
|
|
color: '#fff',
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
emptyOverlay: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
emptyContainer: {
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
|
padding: 32,
|
|
borderRadius: 16,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
color: '#6b7280',
|
|
marginTop: 12,
|
|
},
|
|
emptyText: {
|
|
fontSize: 14,
|
|
color: '#9ca3af',
|
|
marginTop: 4,
|
|
},
|
|
});
|