/** * DraggableJobBlock Component * * Allows drag-and-drop rescheduling and resize of appointments on the timeline. * Features: * - Drag to move: Changes start/end time while preserving duration * - Drag edges to resize: Changes duration * - 15-minute snap: All changes snap to 15-minute intervals * - Permission check: Only allows editing if user has permission */ import React, { useCallback, useMemo } from 'react'; import { View, Text, StyleSheet, Alert, Dimensions, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS, } from 'react-native-reanimated'; import { getJobColor } from '../api/jobs'; import type { JobListItem } from '../types'; const HOUR_HEIGHT = 60; const SNAP_MINUTES = 15; const RESIZE_HANDLE_HEIGHT = 12; const SCREEN_WIDTH = Dimensions.get('window').width; const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70; const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7; // Convert pixels to minutes function pixelsToMinutes(pixels: number): number { return (pixels / HOUR_HEIGHT) * 60; } // Convert minutes to pixels function minutesToPixels(minutes: number): number { return (minutes / 60) * HOUR_HEIGHT; } // Snap to nearest 15 minutes function snapToInterval(minutes: number): number { return Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES; } // Format time for display function formatTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }); } interface DraggableJobBlockProps { job: JobListItem; onPress: () => void; onTimeChange: (jobId: number, newStartTime: Date, newEndTime: Date) => Promise; canEdit: boolean; viewMode: 'day' | 'week'; dayIndex?: number; laneIndex?: number; totalLanes?: number; } export function DraggableJobBlock({ job, onPress, onTimeChange, canEdit, viewMode, dayIndex = 0, laneIndex = 0, totalLanes = 1, }: DraggableJobBlockProps) { 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 initialTop = startHour * HOUR_HEIGHT; const initialHeight = Math.max((endHour - startHour) * HOUR_HEIGHT, 40); // Shared values for animations const translateY = useSharedValue(0); const blockHeight = useSharedValue(initialHeight); const isActive = useSharedValue(false); const resizeMode = useSharedValue<'none' | 'top' | 'bottom'>('none'); // Calculate width and position based on lanes const blockStyle = useMemo(() => { if (viewMode === 'week') { const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes; return { left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth, width: laneWidth - 2, }; } else { const laneWidth = DAY_VIEW_WIDTH / totalLanes; return { left: laneIndex * laneWidth, width: laneWidth - 4, }; } }, [viewMode, dayIndex, laneIndex, totalLanes]); // Calculate new times from current position const calculateNewTimes = useCallback(( deltaY: number, heightDelta: number, mode: 'none' | 'top' | 'bottom' ): { newStart: Date; newEnd: Date } => { const deltaMinutes = pixelsToMinutes(deltaY); const heightDeltaMinutes = pixelsToMinutes(heightDelta); let newStartMinutes = startDate.getHours() * 60 + startDate.getMinutes(); let newEndMinutes = endDate.getHours() * 60 + endDate.getMinutes(); if (mode === 'none') { // Moving the whole block const snappedDelta = snapToInterval(deltaMinutes); newStartMinutes += snappedDelta; newEndMinutes += snappedDelta; } else if (mode === 'top') { // Resizing from top const snappedDelta = snapToInterval(deltaMinutes); newStartMinutes += snappedDelta; // Minimum 15 minutes duration if (newEndMinutes - newStartMinutes < SNAP_MINUTES) { newStartMinutes = newEndMinutes - SNAP_MINUTES; } } else if (mode === 'bottom') { // Resizing from bottom const snappedDelta = snapToInterval(heightDeltaMinutes); newEndMinutes = (startDate.getHours() * 60 + startDate.getMinutes()) + snapToInterval((endHour - startHour) * 60 + heightDeltaMinutes); // Minimum 15 minutes duration if (newEndMinutes - newStartMinutes < SNAP_MINUTES) { newEndMinutes = newStartMinutes + SNAP_MINUTES; } } // Clamp to valid day range (0:00 - 23:59) newStartMinutes = Math.max(0, Math.min(newStartMinutes, 24 * 60 - SNAP_MINUTES)); newEndMinutes = Math.max(SNAP_MINUTES, Math.min(newEndMinutes, 24 * 60)); const newStart = new Date(startDate); newStart.setHours(Math.floor(newStartMinutes / 60), newStartMinutes % 60, 0, 0); const newEnd = new Date(endDate); newEnd.setHours(Math.floor(newEndMinutes / 60), newEndMinutes % 60, 0, 0); return { newStart, newEnd }; }, [startDate, endDate, startHour, endHour]); // Handle the end of a gesture const handleGestureEnd = useCallback(async ( deltaY: number, heightDelta: number, mode: 'none' | 'top' | 'bottom' ) => { if (!canEdit) return; const { newStart, newEnd } = calculateNewTimes(deltaY, heightDelta, mode); // Check if times actually changed if (newStart.getTime() === startDate.getTime() && newEnd.getTime() === endDate.getTime()) { return; } try { await onTimeChange(job.id, newStart, newEnd); } catch (error: any) { Alert.alert('Error', error.message || 'Failed to update appointment time'); } }, [canEdit, calculateNewTimes, startDate, endDate, job.id, onTimeChange]); // Main drag gesture (move the whole block) const dragGesture = Gesture.Pan() .enabled(canEdit) .onStart(() => { isActive.value = true; resizeMode.value = 'none'; }) .onUpdate((event) => { if (resizeMode.value === 'none') { // Snap to 15-minute intervals while dragging const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); translateY.value = snappedY; } }) .onEnd((event) => { isActive.value = false; const finalY = translateY.value; translateY.value = withSpring(0, { damping: 20 }); runOnJS(handleGestureEnd)(finalY, 0, 'none'); }); // Top resize gesture const topResizeGesture = Gesture.Pan() .enabled(canEdit) .onStart(() => { isActive.value = true; resizeMode.value = 'top'; }) .onUpdate((event) => { const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); translateY.value = snappedY; // Height decreases as top moves down const newHeight = Math.max(initialHeight - snappedY, minutesToPixels(SNAP_MINUTES)); blockHeight.value = newHeight; }) .onEnd((event) => { isActive.value = false; const finalY = translateY.value; translateY.value = withSpring(0, { damping: 20 }); blockHeight.value = withSpring(initialHeight, { damping: 20 }); runOnJS(handleGestureEnd)(finalY, 0, 'top'); }); // Bottom resize gesture const bottomResizeGesture = Gesture.Pan() .enabled(canEdit) .onStart(() => { isActive.value = true; resizeMode.value = 'bottom'; }) .onUpdate((event) => { const snappedDelta = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); const newHeight = Math.max(initialHeight + snappedDelta, minutesToPixels(SNAP_MINUTES)); blockHeight.value = newHeight; }) .onEnd((event) => { isActive.value = false; const heightDelta = blockHeight.value - initialHeight; blockHeight.value = withSpring(initialHeight, { damping: 20 }); runOnJS(handleGestureEnd)(0, heightDelta, 'bottom'); }); // Tap gesture for navigation const tapGesture = Gesture.Tap() .onEnd(() => { runOnJS(onPress)(); }); // Combine gestures const composedGesture = Gesture.Race( tapGesture, dragGesture ); // Animated styles const animatedBlockStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], height: blockHeight.value, opacity: isActive.value ? 0.8 : 1, zIndex: isActive.value ? 100 : 1, })); return ( {/* Top resize handle */} {canEdit && ( )} {/* Content */} {formatTime(job.start_time)} {job.title} {viewMode === 'day' && job.customer_name && ( {job.customer_name} )} {viewMode === 'day' && job.address && ( {job.address} )} {/* Bottom resize handle */} {canEdit && ( )} ); } const styles = StyleSheet.create({ jobBlock: { position: 'absolute', backgroundColor: '#fff', borderRadius: 6, borderLeftWidth: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, overflow: 'visible', }, content: { flex: 1, padding: 6, paddingTop: RESIZE_HANDLE_HEIGHT / 2, paddingBottom: RESIZE_HANDLE_HEIGHT / 2, }, resizeHandleTop: { position: 'absolute', top: 0, left: 0, right: 0, height: RESIZE_HANDLE_HEIGHT, justifyContent: 'center', alignItems: 'center', zIndex: 10, }, resizeHandleBottom: { position: 'absolute', bottom: 0, left: 0, right: 0, height: RESIZE_HANDLE_HEIGHT, justifyContent: 'center', alignItems: 'center', zIndex: 10, }, resizeBar: { width: 30, height: 3, backgroundColor: '#d1d5db', borderRadius: 2, }, 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, }, }); export default DraggableJobBlock;