Files
smoothschedule/mobile/field-app/app/(auth)/jobs.tsx
poduck 61882b300f 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>
2025-12-07 01:23:24 -05:00

925 lines
24 KiB
TypeScript

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