- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs - Frontend: Hide management sidebar links from staff members - Add StaffSchedule page with vertical timeline view of appointments - Add StaffHelp page with staff-specific documentation - Return linked_resource_id and can_edit_schedule in user profile for staff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
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 (
|
|
<GestureDetector gesture={composedGesture}>
|
|
<Animated.View
|
|
style={[
|
|
styles.jobBlock,
|
|
{
|
|
top: initialTop,
|
|
borderLeftColor: statusColor,
|
|
...blockStyle,
|
|
},
|
|
animatedBlockStyle,
|
|
]}
|
|
>
|
|
{/* Top resize handle */}
|
|
{canEdit && (
|
|
<GestureDetector gesture={topResizeGesture}>
|
|
<View style={styles.resizeHandleTop}>
|
|
<View style={styles.resizeBar} />
|
|
</View>
|
|
</GestureDetector>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<View style={styles.content}>
|
|
<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>
|
|
)}
|
|
</View>
|
|
|
|
{/* Bottom resize handle */}
|
|
{canEdit && (
|
|
<GestureDetector gesture={bottomResizeGesture}>
|
|
<View style={styles.resizeHandleBottom}>
|
|
<View style={styles.resizeBar} />
|
|
</View>
|
|
</GestureDetector>
|
|
)}
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
);
|
|
}
|
|
|
|
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;
|