feat(mobile): Add field app with date range navigation
- Add React Native Expo field app for mobile staff - Use main /appointments/ endpoint with date range support - Add X-Business-Subdomain header for tenant context - Support day/week view navigation - Remove WebSocket console logging from frontend - Update AppointmentStatus type to include all backend statuses - Add responsive status legend to scheduler header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
41
mobile/field-app/app/(auth)/_layout.tsx
Normal file
41
mobile/field-app/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Auth Group Layout
|
||||
*
|
||||
* Layout for authenticated screens with header navigation.
|
||||
*/
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function AuthLayout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
headerTintColor: '#fff',
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
// Ensure header accounts for status bar on Android
|
||||
headerStatusBarHeight: insets.top,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="jobs"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="job/[id]"
|
||||
options={{
|
||||
title: 'Job Details',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
1083
mobile/field-app/app/(auth)/job/[id].tsx
Normal file
1083
mobile/field-app/app/(auth)/job/[id].tsx
Normal file
File diff suppressed because it is too large
Load Diff
924
mobile/field-app/app/(auth)/jobs.tsx
Normal file
924
mobile/field-app/app/(auth)/jobs.tsx
Normal file
@@ -0,0 +1,924 @@
|
||||
/**
|
||||
* Jobs List Screen
|
||||
*
|
||||
* Displays jobs in a timeline view with day/week toggle.
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getJobColor, jobStatusColors } from '../../src/api/jobs';
|
||||
import { useAuth } from '../../src/hooks/useAuth';
|
||||
import { useJobs } from '../../src/hooks/useJobs';
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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);
|
||||
|
||||
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');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 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 {};
|
||||
|
||||
const grouped: Record<number, JobWithLane[]> = {};
|
||||
weekDays.forEach((_, index) => {
|
||||
grouped[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);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate lane layout for each day
|
||||
Object.keys(grouped).forEach(key => {
|
||||
const dayIndex = parseInt(key);
|
||||
grouped[dayIndex] = calculateLaneLayout(grouped[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 (
|
||||
<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) => (
|
||||
<JobBlock
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPress={() => handleJobPress(job.id)}
|
||||
viewMode={viewMode}
|
||||
laneIndex={job.laneIndex}
|
||||
totalLanes={job.totalLanes}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
|
||||
jobs.map((job) => (
|
||||
<JobBlock
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPress={() => handleJobPress(job.id)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user