feat(staff): Restrict staff permissions and add schedule view

- 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>
This commit is contained in:
poduck
2025-12-07 02:23:00 -05:00
parent 61882b300f
commit 01020861c7
48 changed files with 6156 additions and 148 deletions

View File

@@ -2,6 +2,7 @@
* Jobs List Screen
*
* Displays jobs in a timeline view with day/week toggle.
* Supports drag-and-drop rescheduling and resize if user has permission.
*/
import { useCallback, useState, useRef, useMemo } from 'react';
@@ -16,12 +17,15 @@ import {
Alert,
Dimensions,
} from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQueryClient } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { getJobColor, jobStatusColors } from '../../src/api/jobs';
import { getJobColor, updateAppointmentTime } from '../../src/api/jobs';
import { useAuth } from '../../src/hooks/useAuth';
import { useJobs } from '../../src/hooks/useJobs';
import { DraggableJobBlock } from '../../src/components/DraggableJobBlock';
import type { JobListItem, JobStatus } from '../../src/types';
const HOUR_HEIGHT = 60;
@@ -149,91 +153,8 @@ function getWeekDays(baseDate: Date): Date[] {
return days;
}
function JobBlock({
job,
onPress,
viewMode,
dayIndex = 0,
laneIndex = 0,
totalLanes = 1,
}: {
job: JobListItem;
onPress: () => void;
viewMode: ViewMode;
dayIndex?: number;
laneIndex?: number;
totalLanes?: number;
}) {
// Use time-aware color function (shows red for overdue, yellow for in-progress window, etc.)
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 top = startHour * HOUR_HEIGHT;
const height = Math.max((endHour - startHour) * HOUR_HEIGHT, 40);
// Calculate width and position based on lanes
let blockStyle: { left: number; width: number };
if (viewMode === 'week') {
// Week view: divide the day column by lanes
const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes;
blockStyle = {
left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth,
width: laneWidth - 2,
};
} else {
// Day view: divide the full width by lanes
const laneWidth = DAY_VIEW_WIDTH / totalLanes;
blockStyle = {
left: laneIndex * laneWidth,
width: laneWidth - 4,
};
}
return (
<TouchableOpacity
style={[
styles.jobBlock,
{
top,
height,
borderLeftColor: statusColor,
...blockStyle,
},
]}
onPress={onPress}
activeOpacity={0.8}
>
<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>
)}
</TouchableOpacity>
);
}
// Note: JobBlock functionality moved to DraggableJobBlock component
// which supports drag-and-drop rescheduling and resize
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
const hours = [];
@@ -302,12 +223,16 @@ function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
export default function JobsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient();
const { user, logout } = useAuth();
const [refreshing, setRefreshing] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [selectedDate, setSelectedDate] = useState(new Date());
const scrollRef = useRef<ScrollView>(null);
// Check if user can edit schedule
const canEditSchedule = user?.can_edit_schedule ?? false;
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
// Calculate the date range to fetch based on view mode
@@ -374,6 +299,24 @@ export default function JobsScreen() {
);
};
// Handle drag-and-drop time changes
const handleTimeChange = useCallback(async (
jobId: number,
newStartTime: Date,
newEndTime: Date
) => {
try {
await updateAppointmentTime(jobId, {
start_time: newStartTime.toISOString(),
end_time: newEndTime.toISOString(),
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['jobs'] });
} catch (error: any) {
throw new Error(error.response?.data?.detail || 'Failed to update appointment');
}
}, [queryClient]);
// Scroll to current time on mount
const scrollToNow = useCallback(() => {
const now = new Date();
@@ -409,23 +352,25 @@ export default function JobsScreen() {
const jobsByDay = useMemo(() => {
if (viewMode !== 'week') return {};
const grouped: Record<number, JobWithLane[]> = {};
// First group raw jobs by day
const tempGrouped: Record<number, JobListItem[]> = {};
weekDays.forEach((_, index) => {
grouped[index] = [];
tempGrouped[index] = [];
});
filteredJobs.forEach(job => {
const jobDate = new Date(job.start_time);
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
if (dayIndex !== -1) {
grouped[dayIndex].push(job);
tempGrouped[dayIndex].push(job);
}
});
// Calculate lane layout for each day
Object.keys(grouped).forEach(key => {
// Then calculate lane layout for each day
const grouped: Record<number, JobWithLane[]> = {};
Object.keys(tempGrouped).forEach(key => {
const dayIndex = parseInt(key);
grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]);
grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]);
});
return grouped;
@@ -459,6 +404,7 @@ export default function JobsScreen() {
});
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<View>
@@ -577,10 +523,12 @@ export default function JobsScreen() {
]}>
{viewMode === 'day' ? (
dayJobsWithLanes.map((job) => (
<JobBlock
<DraggableJobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
onTimeChange={handleTimeChange}
canEdit={canEditSchedule}
viewMode={viewMode}
laneIndex={job.laneIndex}
totalLanes={job.totalLanes}
@@ -589,10 +537,12 @@ export default function JobsScreen() {
) : (
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
jobs.map((job) => (
<JobBlock
<DraggableJobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
onTimeChange={handleTimeChange}
canEdit={false}
viewMode={viewMode}
dayIndex={parseInt(dayIndex)}
laneIndex={job.laneIndex}
@@ -616,6 +566,7 @@ export default function JobsScreen() {
)}
</ScrollView>
</View>
</GestureHandlerRootView>
);
}

View File

@@ -2,5 +2,6 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};

View File

@@ -25,7 +25,9 @@
"expo-task-manager": "~14.0.9",
"react": "^19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},
@@ -1366,6 +1368,22 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-template-literals": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
@@ -1513,6 +1531,18 @@
"node": ">=6.9.0"
}
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
@@ -3151,6 +3181,12 @@
"@types/node": "*"
}
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -3202,7 +3238,7 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5020,7 +5056,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -7292,6 +7328,21 @@
"hermes-estree": "0.32.0"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@@ -10022,9 +10073,9 @@
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10040,6 +10091,19 @@
"ws": "^7"
}
},
"node_modules/react-dom": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.1"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -10121,6 +10185,21 @@
}
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"license": "MIT",
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
@@ -10153,6 +10232,34 @@
}
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*",
"react-native-worklets": ">=0.5.0"
}
},
"node_modules/react-native-reanimated/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-safe-area-context": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
@@ -10178,6 +10285,81 @@
"react-native": "*"
}
},
"node_modules/react-native-worklets": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz",
"integrity": "sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "7.27.1",
"@babel/plugin-transform-class-properties": "7.27.1",
"@babel/plugin-transform-classes": "7.28.4",
"@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
"@babel/plugin-transform-optional-chaining": "7.27.1",
"@babel/plugin-transform-shorthand-properties": "7.27.1",
"@babel/plugin-transform-template-literals": "7.27.1",
"@babel/plugin-transform-unicode-regex": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"convert-source-map": "2.0.0",
"semver": "7.7.3"
},
"peerDependencies": {
"@babel/core": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/react-native-worklets/node_modules/@babel/preset-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/react-native-worklets/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -10676,6 +10858,13 @@
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",

View File

@@ -28,7 +28,9 @@
"expo-task-manager": "~14.0.9",
"react": "^19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},

View File

@@ -185,3 +185,44 @@ export async function rescheduleJob(
);
return response.data;
}
/**
* Update an appointment's time via PATCH to /appointments/{id}/
* Used for drag-and-drop rescheduling on the mobile timeline
*/
export async function updateAppointmentTime(
appointmentId: number,
data: { start_time: string; end_time: string }
): Promise<JobListItem> {
const token = await getAuthToken();
const userData = await getUserData();
const subdomain = userData?.business_subdomain;
const apiUrl = getAppointmentsApiUrl();
const response = await axios.patch<any>(`${apiUrl}/appointments/${appointmentId}/`, data, {
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Token ${token}` }),
...(subdomain && { 'X-Business-Subdomain': subdomain }),
},
});
// Transform response to match JobListItem format
const apt = response.data;
const start = new Date(apt.start_time);
const end = new Date(apt.end_time);
const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000);
return {
id: apt.id,
title: apt.title || apt.service_name || 'Appointment',
start_time: apt.start_time,
end_time: apt.end_time,
status: apt.status as JobStatus,
status_display: jobStatusLabels[apt.status as JobStatus] || apt.status,
customer_name: apt.customer_name || null,
address: apt.address || apt.location || null,
service_name: apt.service_name || null,
duration_minutes: durationMinutes,
};
}

View File

@@ -0,0 +1,415 @@
/**
* 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;

View File

@@ -12,6 +12,7 @@ export interface User {
business_name?: string;
business_subdomain?: string;
can_use_masked_calls?: boolean;
can_edit_schedule?: boolean;
}
export interface AuthResponse {