- 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>
1084 lines
30 KiB
TypeScript
1084 lines
30 KiB
TypeScript
/**
|
|
* 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<DrivingInfo | null> {
|
|
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 (
|
|
<TouchableOpacity
|
|
style={[styles.actionButton, disabled && styles.actionButtonDisabled]}
|
|
onPress={onPress}
|
|
disabled={disabled}
|
|
>
|
|
<View style={[styles.actionIcon, { backgroundColor: color }]}>
|
|
<Ionicons name={icon} size={24} color="#fff" />
|
|
</View>
|
|
<Text style={styles.actionLabel}>{label}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.statusButton,
|
|
isCurrent && { backgroundColor: color, borderColor: color },
|
|
!isAllowed && !isCurrent && styles.statusButtonDisabled,
|
|
]}
|
|
onPress={() => onPress(status)}
|
|
disabled={isCurrent || !isAllowed || isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator size="small" color={isCurrent ? '#fff' : color} />
|
|
) : (
|
|
<>
|
|
{isCurrent && <Ionicons name="checkmark-circle" size={16} color="#fff" />}
|
|
<Text
|
|
style={[
|
|
styles.statusButtonText,
|
|
isCurrent && { color: '#fff' },
|
|
!isCurrent && { color },
|
|
]}
|
|
>
|
|
{jobStatusLabels[status]}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<View style={styles.mapPlaceholder}>
|
|
<ActivityIndicator size="small" color="#2563eb" />
|
|
<Text style={styles.mapPlaceholderText}>Loading map...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!jobLocation && !userLocation) {
|
|
return (
|
|
<View style={styles.mapPlaceholder}>
|
|
<Ionicons name="map-outline" size={32} color="#9ca3af" />
|
|
<Text style={styles.mapPlaceholderText}>Location not available</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<MapView
|
|
style={styles.map}
|
|
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : undefined}
|
|
initialRegion={{
|
|
latitude: centerLat,
|
|
longitude: centerLng,
|
|
latitudeDelta: latDelta,
|
|
longitudeDelta: lngDelta,
|
|
}}
|
|
>
|
|
{jobLocation && (
|
|
<Marker
|
|
coordinate={{ latitude: jobLocation.lat, longitude: jobLocation.lng }}
|
|
title="Job Location"
|
|
pinColor="#ef4444"
|
|
/>
|
|
)}
|
|
{userLocation && (
|
|
<Marker
|
|
coordinate={userLocation}
|
|
title="Your Location"
|
|
pinColor="#2563eb"
|
|
/>
|
|
)}
|
|
</MapView>
|
|
);
|
|
}
|
|
|
|
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<DrivingInfo | null>(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 (
|
|
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
|
|
<ActivityIndicator size="large" color="#2563eb" />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error || !job) {
|
|
return (
|
|
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
|
|
<Ionicons name="alert-circle-outline" size={48} color="#ef4444" />
|
|
<Text style={styles.errorText}>Failed to load job details</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
|
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const statusColor = jobStatusColors[job.status] || '#6b7280';
|
|
|
|
// Extract address from notes
|
|
const addressFromNotes = job.notes?.replace(/^Address:\s*/i, '') || null;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={['#2563eb']}
|
|
tintColor="#2563eb"
|
|
/>
|
|
}
|
|
>
|
|
{/* Status Banner - no extra safe area needed, Stack header handles it */}
|
|
<View style={[styles.statusBanner, { backgroundColor: statusColor }]}>
|
|
<Text style={styles.statusBannerText}>{job.status_display}</Text>
|
|
{isTracking && (
|
|
<View style={styles.trackingIndicator}>
|
|
<Ionicons name="location" size={16} color="#fff" />
|
|
<Text style={styles.trackingText}>Tracking</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Map Card */}
|
|
<View style={styles.mapCard}>
|
|
<JobMap
|
|
address={addressFromNotes}
|
|
userLocation={userLocation}
|
|
onJobLocationFound={setJobLocation}
|
|
/>
|
|
</View>
|
|
|
|
{/* Route Info Card - shows distance and estimated time */}
|
|
{userLocation && jobLocation && (
|
|
<View style={styles.routeInfoCard}>
|
|
<View style={styles.routeInfoItem}>
|
|
<Ionicons name="navigate-outline" size={20} color="#2563eb" />
|
|
<View>
|
|
{isLoadingRoute ? (
|
|
<ActivityIndicator size="small" color="#2563eb" />
|
|
) : (
|
|
<>
|
|
<Text style={styles.routeInfoValue}>
|
|
{drivingInfo?.distance || `${calculateStraightLineDistance(
|
|
userLocation.latitude,
|
|
userLocation.longitude,
|
|
jobLocation.lat,
|
|
jobLocation.lng
|
|
).toFixed(1)} mi`}
|
|
</Text>
|
|
<Text style={styles.routeInfoLabel}>
|
|
{drivingInfo ? 'Driving' : 'Straight line'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<View style={styles.routeInfoDivider} />
|
|
<View style={styles.routeInfoItem}>
|
|
<Ionicons name="time-outline" size={20} color="#2563eb" />
|
|
<View>
|
|
{isLoadingRoute ? (
|
|
<ActivityIndicator size="small" color="#2563eb" />
|
|
) : (
|
|
<>
|
|
<Text style={styles.routeInfoValue}>
|
|
{drivingInfo?.duration || estimateTravelTime(
|
|
calculateStraightLineDistance(
|
|
userLocation.latitude,
|
|
userLocation.longitude,
|
|
jobLocation.lat,
|
|
jobLocation.lng
|
|
)
|
|
)}
|
|
</Text>
|
|
<Text style={styles.routeInfoLabel}>
|
|
{drivingInfo ? 'ETA' : 'Est.'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<View style={styles.routeInfoDivider} />
|
|
<TouchableOpacity style={styles.routeInfoItem} onPress={handleNavigate}>
|
|
<Ionicons name="car-outline" size={20} color="#10b981" />
|
|
<View>
|
|
<Text style={[styles.routeInfoValue, { color: '#10b981' }]}>Go</Text>
|
|
<Text style={styles.routeInfoLabel}>Navigate</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{/* Job Info Card */}
|
|
<View style={styles.card}>
|
|
<Text style={styles.jobTitle}>{job.title}</Text>
|
|
|
|
<View style={styles.timeRow}>
|
|
<Ionicons name="time-outline" size={20} color="#6b7280" />
|
|
<Text style={styles.timeText}>
|
|
{formatDateTime(job.start_time)} - {formatDateTime(job.end_time)}
|
|
</Text>
|
|
</View>
|
|
|
|
{job.service && (
|
|
<View style={styles.infoRow}>
|
|
<Ionicons name="briefcase-outline" size={20} color="#6b7280" />
|
|
<Text style={styles.infoText}>
|
|
{job.service.name} ({job.duration_minutes} min)
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Customer Card */}
|
|
{job.customer && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.sectionTitle}>Customer</Text>
|
|
|
|
<View style={styles.customerInfo}>
|
|
<View style={styles.customerAvatar}>
|
|
<Ionicons name="person" size={24} color="#fff" />
|
|
</View>
|
|
<View style={styles.customerDetails}>
|
|
<Text style={styles.customerName}>{job.customer.name}</Text>
|
|
{job.customer.phone_masked && (
|
|
<Text style={styles.customerPhone}>{job.customer.phone_masked}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Quick Actions */}
|
|
<View style={styles.actionsRow}>
|
|
<ActionButton
|
|
icon="call-outline"
|
|
label="Call"
|
|
onPress={handleCall}
|
|
color="#10b981"
|
|
disabled={callMutation.isPending}
|
|
/>
|
|
<ActionButton
|
|
icon="chatbubble-outline"
|
|
label="SMS"
|
|
onPress={() => setSmsModalVisible(true)}
|
|
color="#3b82f6"
|
|
/>
|
|
<ActionButton
|
|
icon="navigate-outline"
|
|
label="Navigate"
|
|
onPress={handleNavigate}
|
|
color="#8b5cf6"
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Notes Card */}
|
|
{job.notes && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.sectionTitle}>Notes / Address</Text>
|
|
<Text style={styles.notesText}>{job.notes}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Status Actions Card */}
|
|
<View style={styles.card}>
|
|
<Text style={styles.sectionTitle}>Update Status</Text>
|
|
|
|
<View style={styles.statusButtonsContainer}>
|
|
{(['EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'NOSHOW', 'CANCELED'] as JobStatus[]).map(
|
|
(status) => (
|
|
<StatusButton
|
|
key={status}
|
|
status={status}
|
|
currentStatus={job.status}
|
|
allowedTransitions={job.allowed_transitions}
|
|
onPress={handleStatusChange}
|
|
isLoading={statusMutation.isPending}
|
|
/>
|
|
)
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Status History */}
|
|
{job.status_history && job.status_history.length > 0 && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.sectionTitle}>History</Text>
|
|
{job.status_history.slice(0, 5).map((item, index) => (
|
|
<View key={index} style={styles.historyItem}>
|
|
<View style={styles.historyDot} />
|
|
<View style={styles.historyContent}>
|
|
<Text style={styles.historyText}>
|
|
{item.old_status} → {item.new_status}
|
|
</Text>
|
|
<Text style={styles.historyMeta}>
|
|
{item.changed_by} • {new Date(item.changed_at).toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
{/* SMS Modal */}
|
|
<Modal
|
|
visible={smsModalVisible}
|
|
animationType="slide"
|
|
transparent={true}
|
|
onRequestClose={() => setSmsModalVisible(false)}
|
|
>
|
|
<View style={styles.modalOverlay}>
|
|
<View style={[styles.modalContent, { paddingBottom: insets.bottom + 20 }]}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={styles.modalTitle}>Send SMS to Customer</Text>
|
|
<TouchableOpacity onPress={() => setSmsModalVisible(false)}>
|
|
<Ionicons name="close" size={24} color="#6b7280" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TextInput
|
|
style={styles.smsInput}
|
|
placeholder="Type your message..."
|
|
placeholderTextColor="#9ca3af"
|
|
value={smsMessage}
|
|
onChangeText={setSmsMessage}
|
|
multiline
|
|
maxLength={1600}
|
|
/>
|
|
|
|
<Text style={styles.charCount}>{smsMessage.length}/1600</Text>
|
|
|
|
<TouchableOpacity
|
|
style={[styles.sendButton, smsMutation.isPending && styles.sendButtonDisabled]}
|
|
onPress={handleSendSMS}
|
|
disabled={smsMutation.isPending}
|
|
>
|
|
{smsMutation.isPending ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Text style={styles.sendButtonText}>Send Message</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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',
|
|
},
|
|
});
|