Files
smoothschedule/mobile/field-app/app/(auth)/job/[id].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

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