/** * Job Detail Screen * * Full job details with status management, calling, SMS, map, and navigation. */ import { useState, useCallback, useEffect } from 'react'; import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator, Linking, Platform, RefreshControl, TextInput, Modal, Dimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Ionicons } from '@expo/vector-icons'; import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps'; import * as Location from 'expo-location'; import { setJobStatus, startEnRoute, callCustomer, sendSMS, jobStatusColors, jobStatusLabels, } from '../../../src/api/jobs'; import { useAuth } from '../../../src/hooks/useAuth'; import { useJob } from '../../../src/hooks/useJobs'; import locationService from '../../../src/services/location'; import type { JobStatus, JobDetail } from '../../../src/types'; const SCREEN_WIDTH = Dimensions.get('window').width; const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY; interface DrivingInfo { distance: string; // e.g., "5.2 mi" duration: string; // e.g., "12 min" distanceValue: number; // meters durationValue: number; // seconds } // Fetch driving distance and time from Google Distance Matrix API async function fetchDrivingDistance( originLat: number, originLng: number, destLat: number, destLng: number ): Promise { if (!GOOGLE_MAPS_API_KEY) { console.log('[Route] No Google Maps API key configured'); return null; } try { const url = `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${originLat},${originLng}&destinations=${destLat},${destLng}&units=imperial&key=${GOOGLE_MAPS_API_KEY}`; const response = await fetch(url); const data = await response.json(); if (data.status === 'OK' && data.rows?.[0]?.elements?.[0]?.status === 'OK') { const element = data.rows[0].elements[0]; return { distance: element.distance.text, duration: element.duration.text, distanceValue: element.distance.value, durationValue: element.duration.value, }; } else { console.log('[Route] Distance Matrix API error:', data.status); return null; } } catch (error) { console.log('[Route] Failed to fetch driving distance:', error); return null; } } // Fallback: Calculate straight-line distance (Haversine formula) function calculateStraightLineDistance( lat1: number, lon1: number, lat2: number, lon2: number ): number { const R = 3959; // Earth's radius in miles const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } // Fallback: Estimate travel time based on distance (assumes ~30 mph average) function estimateTravelTime(distanceMiles: number): string { const avgSpeedMph = 30; const minutes = Math.round((distanceMiles / avgSpeedMph) * 60); if (minutes < 1) return '< 1 min'; if (minutes < 60) return `${minutes} min`; const hours = Math.floor(minutes / 60); const remainingMins = minutes % 60; return remainingMins > 0 ? `${hours} hr ${remainingMins} min` : `${hours} hr`; } function formatDateTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } // Simple geocoding - extract coordinates from address text or use fallback async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> { try { const results = await Location.geocodeAsync(address); if (results.length > 0) { return { lat: results[0].latitude, lng: results[0].longitude }; } } catch (error) { console.log('Geocoding failed:', error); } return null; } function ActionButton({ icon, label, onPress, color = '#2563eb', disabled = false, }: { icon: keyof typeof Ionicons.glyphMap; label: string; onPress: () => void; color?: string; disabled?: boolean; }) { return ( {label} ); } function StatusButton({ status, currentStatus, allowedTransitions, onPress, isLoading, }: { status: JobStatus; currentStatus: JobStatus; allowedTransitions: JobStatus[]; onPress: (status: JobStatus) => void; isLoading: boolean; }) { const isAllowed = allowedTransitions.includes(status); const isCurrent = currentStatus === status; const color = jobStatusColors[status] || '#6b7280'; if (!isAllowed && !isCurrent) return null; return ( onPress(status)} disabled={isCurrent || !isAllowed || isLoading} > {isLoading ? ( ) : ( <> {isCurrent && } {jobStatusLabels[status]} )} ); } function JobMap({ address, userLocation, onJobLocationFound, }: { address: string | null; userLocation: { latitude: number; longitude: number } | null; onJobLocationFound?: (location: { lat: number; lng: number } | null) => void; }) { const [jobLocation, setJobLocation] = useState<{ lat: number; lng: number } | null>(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { async function loadLocation() { if (address) { const coords = await geocodeAddress(address); setJobLocation(coords); onJobLocationFound?.(coords); } else { onJobLocationFound?.(null); } setIsLoading(false); } loadLocation(); }, [address]); if (isLoading) { return ( Loading map... ); } if (!jobLocation && !userLocation) { return ( Location not available ); } const centerLat = jobLocation?.lat || userLocation?.latitude || 30.2672; const centerLng = jobLocation?.lng || userLocation?.longitude || -97.7431; // Calculate region to fit both points let latDelta = 0.02; let lngDelta = 0.02; if (jobLocation && userLocation) { const latDiff = Math.abs(jobLocation.lat - userLocation.latitude); const lngDiff = Math.abs(jobLocation.lng - userLocation.longitude); latDelta = Math.max(latDiff * 1.5, 0.02); lngDelta = Math.max(lngDiff * 1.5, 0.02); } return ( {jobLocation && ( )} {userLocation && ( )} ); } export default function JobDetailScreen() { const insets = useSafeAreaInsets(); const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const queryClient = useQueryClient(); const { user } = useAuth(); const [refreshing, setRefreshing] = useState(false); const [smsModalVisible, setSmsModalVisible] = useState(false); const [smsMessage, setSmsMessage] = useState(''); const [isTracking, setIsTracking] = useState(false); const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null); const [jobLocation, setJobLocation] = useState<{ lat: number; lng: number } | null>(null); const [drivingInfo, setDrivingInfo] = useState(null); const [isLoadingRoute, setIsLoadingRoute] = useState(false); const jobId = parseInt(id, 10); // Use the job hook with WebSocket integration for real-time updates const { data: job, isLoading, error, refresh } = useJob(jobId); // Get user's current location useEffect(() => { async function getLocation() { try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status === 'granted') { const location = await Location.getCurrentPositionAsync({}); setUserLocation({ latitude: location.coords.latitude, longitude: location.coords.longitude, }); } } catch (error) { console.log('Failed to get location:', error); } } getLocation(); }, []); // Check tracking status on mount useEffect(() => { const checkTracking = async () => { const tracking = await locationService.isTracking(); const activeJobId = locationService.getActiveJobId(); setIsTracking(tracking && activeJobId === jobId); }; checkTracking(); }, [jobId]); // Fetch driving distance when both locations are available useEffect(() => { async function fetchRoute() { if (!userLocation || !jobLocation) return; setIsLoadingRoute(true); const info = await fetchDrivingDistance( userLocation.latitude, userLocation.longitude, jobLocation.lat, jobLocation.lng ); setDrivingInfo(info); setIsLoadingRoute(false); } fetchRoute(); }, [userLocation, jobLocation]); // Status mutation const statusMutation = useMutation({ mutationFn: ({ status, notes }: { status: JobStatus; notes?: string }) => { if (status === 'EN_ROUTE') { return startEnRoute(jobId); } return setJobStatus(jobId, { status, notes }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['job', jobId] }); queryClient.invalidateQueries({ queryKey: ['jobs'] }); }, onError: (error: any) => { Alert.alert('Error', error?.error || 'Failed to update status'); }, }); // Call mutation const callMutation = useMutation({ mutationFn: () => callCustomer(jobId), onSuccess: (data) => { Alert.alert( 'Call Initiated', data.message || 'Your phone will ring shortly to connect you with the customer.' ); }, onError: (error: any) => { Alert.alert('Call Failed', error?.error || 'Failed to initiate call'); }, }); // SMS mutation const smsMutation = useMutation({ mutationFn: (message: string) => sendSMS(jobId, { message }), onSuccess: () => { Alert.alert('SMS Sent', 'Your message has been sent to the customer.'); setSmsModalVisible(false); setSmsMessage(''); }, onError: (error: any) => { Alert.alert('SMS Failed', error?.error || 'Failed to send SMS'); }, }); const onRefresh = useCallback(async () => { setRefreshing(true); await refresh(); setRefreshing(false); }, [refresh]); const handleStatusChange = async (newStatus: JobStatus) => { if (newStatus === 'EN_ROUTE') { // Start location tracking const location = await locationService.getCurrentLocation(); if (location) { statusMutation.mutate({ status: newStatus }); await locationService.startTracking(jobId); setIsTracking(true); } else { Alert.alert( 'Location Required', 'Please enable location services to start en-route tracking.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Open Settings', onPress: () => Linking.openSettings() }, ] ); } } else if (newStatus === 'COMPLETED' || newStatus === 'CANCELED') { // Stop location tracking await locationService.stopTracking(); setIsTracking(false); statusMutation.mutate({ status: newStatus }); } else { statusMutation.mutate({ status: newStatus }); } }; const handleNavigate = () => { if (!job?.notes) return; // Build navigation URL const address = job.notes; const encodedAddress = encodeURIComponent(address); const url = Platform.select({ ios: `maps://app?daddr=${encodedAddress}`, android: `google.navigation:q=${encodedAddress}`, }); if (url) { Linking.canOpenURL(url).then((supported) => { if (supported) { Linking.openURL(url); } else { // Fallback to Google Maps web Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}`); } }); } }; const handleCall = () => { callMutation.mutate(); }; const handleSendSMS = () => { if (!smsMessage.trim()) { Alert.alert('Error', 'Please enter a message'); return; } smsMutation.mutate(smsMessage.trim()); }; if (isLoading && !refreshing) { return ( ); } if (error || !job) { return ( Failed to load job details Try Again ); } const statusColor = jobStatusColors[job.status] || '#6b7280'; // Extract address from notes const addressFromNotes = job.notes?.replace(/^Address:\s*/i, '') || null; return ( } > {/* Status Banner - no extra safe area needed, Stack header handles it */} {job.status_display} {isTracking && ( Tracking )} {/* Map Card */} {/* Route Info Card - shows distance and estimated time */} {userLocation && jobLocation && ( {isLoadingRoute ? ( ) : ( <> {drivingInfo?.distance || `${calculateStraightLineDistance( userLocation.latitude, userLocation.longitude, jobLocation.lat, jobLocation.lng ).toFixed(1)} mi`} {drivingInfo ? 'Driving' : 'Straight line'} )} {isLoadingRoute ? ( ) : ( <> {drivingInfo?.duration || estimateTravelTime( calculateStraightLineDistance( userLocation.latitude, userLocation.longitude, jobLocation.lat, jobLocation.lng ) )} {drivingInfo ? 'ETA' : 'Est.'} )} Go Navigate )} {/* Job Info Card */} {job.title} {formatDateTime(job.start_time)} - {formatDateTime(job.end_time)} {job.service && ( {job.service.name} ({job.duration_minutes} min) )} {/* Customer Card */} {job.customer && ( Customer {job.customer.name} {job.customer.phone_masked && ( {job.customer.phone_masked} )} {/* Quick Actions */} setSmsModalVisible(true)} color="#3b82f6" /> )} {/* Notes Card */} {job.notes && ( Notes / Address {job.notes} )} {/* Status Actions Card */} Update Status {(['EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'NOSHOW', 'CANCELED'] as JobStatus[]).map( (status) => ( ) )} {/* Status History */} {job.status_history && job.status_history.length > 0 && ( History {job.status_history.slice(0, 5).map((item, index) => ( {item.old_status} → {item.new_status} {item.changed_by} • {new Date(item.changed_at).toLocaleString()} ))} )} {/* SMS Modal */} setSmsModalVisible(false)} > Send SMS to Customer setSmsModalVisible(false)}> {smsMessage.length}/1600 {smsMutation.isPending ? ( ) : ( Send Message )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f3f4f6', }, scrollContent: { paddingBottom: 32, }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: '#f3f4f6', }, errorText: { marginTop: 12, fontSize: 16, color: '#ef4444', }, retryButton: { marginTop: 16, backgroundColor: '#2563eb', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8, }, retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600', }, statusBanner: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: 12, gap: 12, }, statusBannerText: { color: '#fff', fontSize: 16, fontWeight: '600', }, trackingIndicator: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.2)', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, gap: 4, }, trackingText: { color: '#fff', fontSize: 12, fontWeight: '500', }, mapCard: { marginHorizontal: 16, marginTop: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#fff', shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, }, map: { width: '100%', height: 180, }, mapPlaceholder: { width: '100%', height: 180, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f3f4f6', }, mapPlaceholderText: { marginTop: 8, fontSize: 14, color: '#9ca3af', }, routeInfoCard: { flexDirection: 'row', backgroundColor: '#fff', marginHorizontal: 16, marginTop: 12, borderRadius: 12, padding: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, alignItems: 'center', justifyContent: 'space-around', }, routeInfoItem: { flexDirection: 'row', alignItems: 'center', gap: 8, }, routeInfoValue: { fontSize: 16, fontWeight: '600', color: '#111827', }, routeInfoLabel: { fontSize: 11, color: '#6b7280', }, routeInfoDivider: { width: 1, height: 32, backgroundColor: '#e5e7eb', }, card: { backgroundColor: '#fff', marginHorizontal: 16, marginTop: 16, borderRadius: 12, padding: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, }, jobTitle: { fontSize: 20, fontWeight: '600', color: '#111827', marginBottom: 12, }, timeRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8, }, timeText: { fontSize: 14, color: '#4b5563', }, infoRow: { flexDirection: 'row', alignItems: 'center', gap: 8, }, infoText: { fontSize: 14, color: '#4b5563', }, sectionTitle: { fontSize: 16, fontWeight: '600', color: '#374151', marginBottom: 12, }, customerInfo: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16, }, customerAvatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#6b7280', justifyContent: 'center', alignItems: 'center', }, customerDetails: { flex: 1, }, customerName: { fontSize: 16, fontWeight: '600', color: '#111827', }, customerPhone: { fontSize: 14, color: '#6b7280', marginTop: 2, }, actionsRow: { flexDirection: 'row', justifyContent: 'space-around', }, actionButton: { alignItems: 'center', padding: 8, }, actionButtonDisabled: { opacity: 0.5, }, actionIcon: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', marginBottom: 4, }, actionLabel: { fontSize: 12, color: '#4b5563', }, notesText: { fontSize: 14, color: '#4b5563', lineHeight: 20, }, statusButtonsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, }, statusButton: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20, borderWidth: 2, borderColor: '#e5e7eb', }, statusButtonDisabled: { opacity: 0.5, }, statusButtonText: { fontSize: 14, fontWeight: '500', }, historyItem: { flexDirection: 'row', gap: 12, marginBottom: 12, }, historyDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#6b7280', marginTop: 6, }, historyContent: { flex: 1, }, historyText: { fontSize: 14, color: '#374151', }, historyMeta: { fontSize: 12, color: '#9ca3af', marginTop: 2, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end', }, modalContent: { backgroundColor: '#fff', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, }, modalTitle: { fontSize: 18, fontWeight: '600', color: '#111827', }, smsInput: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 8, padding: 12, fontSize: 16, minHeight: 100, textAlignVertical: 'top', color: '#111827', }, charCount: { textAlign: 'right', fontSize: 12, color: '#9ca3af', marginTop: 4, }, sendButton: { backgroundColor: '#2563eb', borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginTop: 16, }, sendButtonDisabled: { backgroundColor: '#93c5fd', }, sendButtonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, });