Initial commit: code as received (Create/Anything export)

Insole-production time tracker exported from the Create/Anything AI
platform. Baseline snapshot before any reverse-engineering or cleanup.

- apps/mobile: Expo Router app (iOS/Android/web), the only workspace
- publisher/: standalone OpenNext/AWS deploy tooling for the web side
- Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
Bas van Rossem
2026-06-17 10:19:33 +02:00
commit d94d0b188b
192 changed files with 50705 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { Tabs } from 'expo-router';
import { Timer, History, Settings } from 'lucide-react-native';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
paddingTop: 4,
},
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#6B7280',
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '500',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Stopwatch',
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'Geschiedenis',
tabBarIcon: ({ color }) => <History color={color} size={24} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Instellingen',
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
import { useQuery } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
export default function HistoryScreen() {
const insets = useSafeAreaInsets();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const { data: logs = [], isLoading } = useQuery({
queryKey: ['logs'],
queryFn: async () => {
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
if (!res.ok) throw new Error('Failed to fetch logs');
return res.json();
},
});
const handleExport = async () => {
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
const supported = await Linking.canOpenURL(exportUrl);
if (supported) {
await Linking.openURL(exportUrl);
} else {
Alert.alert('Fout', 'Kan de export-URL niet openen');
}
};
const formatDuration = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) return `${hrs}h ${mins}m`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
if (!fontsLoaded && !fontError) return null;
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Geschiedenis
</Text>
<TouchableOpacity
onPress={handleExport}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
gap: 6,
}}
>
<Download color="#2563EB" size={16} />
<Text
style={{
color: '#2563EB',
fontWeight: '500',
fontSize: 13,
fontFamily: 'Inter_600SemiBold',
}}
>
Exporteer CSV
</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
{logs.length === 0 && !isLoading ? (
<View style={{ alignItems: 'center', marginTop: 100 }}>
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
Nog geen opgeslagen sessies.
</Text>
</View>
) : (
logs.map((log: any) => (
<View
key={log.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
padding: 16,
marginBottom: 12,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<View>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
fontFamily: 'Inter_600SemiBold',
}}
>
{log.task_name}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Calendar color="#6B7280" size={12} />
<Text
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
>
{formatDate(log.start_time)} {formatTime(log.start_time)}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
{log.insole_type && (
<View
style={{
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#374151',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.insole_type}
</Text>
</View>
)}
{log.pair_count != null && (
<View
style={{
backgroundColor: '#EFF6FF',
borderWidth: 1,
borderColor: '#BFDBFE',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Layers color="#2563EB" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#2563EB',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
</Text>
</View>
)}
<View
style={{
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Clock color="#111827" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
{formatDuration(log.duration_seconds)}
</Text>
</View>
</View>
</View>
</View>
))
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,658 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
TextInput,
Modal,
Animated,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof INSOLE_TYPES)[number];
export default function TimerScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
// fontError: if fonts fail to load on Android we still render (no freeze)
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [showPicker, setShowPicker] = useState(false);
const [discardPending, setDiscardPending] = useState(false);
const [insoleCount, setInsoleCount] = useState(2);
const [insoleCountText, setInsoleCountText] = useState('2');
const timerRef = useRef<NodeJS.Timeout | null>(null);
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const saveLogMutation = useMutation({
mutationFn: async (log: any) => {
const res = await fetch(`${BASE_URL}/api/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log),
});
if (!res.ok) throw new Error('Failed to save log');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
useEffect(() => {
if (isRunning && !isPaused) {
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isRunning, isPaused]);
const openPicker = () => {
setShowPicker(true);
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
};
const closePicker = () => {
Animated.timing(slideAnim, {
toValue: SHEET_HEIGHT,
duration: 250,
useNativeDriver: true,
}).start(() => setShowPicker(false));
};
const handleStart = () => {
if (!activeTaskId) return;
setIsRunning(true);
setIsPaused(false);
setStartTime(new Date());
};
const handlePause = () => setIsPaused(true);
const handleResume = () => setIsPaused(false);
const handleStop = () => {
if (!activeTaskId || !startTime) return;
setIsRunning(false);
setIsPaused(false);
const endTime = new Date();
saveLogMutation.mutate({
task_id: activeTaskId,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
duration_seconds: elapsedTime,
pair_count: insoleCount,
insole_type: insoleType,
});
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
};
const handleDiscard = () => {
if (!discardPending) {
setDiscardPending(true);
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
} else {
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
setIsRunning(false);
setIsPaused(false);
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
}
};
const handleInsoleCountChange = (text: string) => {
setInsoleCountText(text);
const parsed = parseInt(text, 10);
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
};
const adjustInsoleCount = (delta: number) => {
const next = Math.max(1, insoleCount + delta);
setInsoleCount(next);
setInsoleCountText(String(next));
};
const formatTime = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
if (!fontsLoaded && !fontError) return null;
const regular = fontError ? undefined : 'Inter_400Regular';
const semibold = fontError ? undefined : 'Inter_600SemiBold';
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
const canStart = !!activeTaskId;
const filteredTasks = tasks.filter((t: any) =>
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
);
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<ScrollView contentContainerStyle={{ padding: 24 }}>
{/* 1. Type zool */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type zool
</Text>
<View style={{ flexDirection: 'row' }}>
{INSOLE_TYPES.map((type, i) => {
const selected = insoleType === type;
return (
<TouchableOpacity
key={type}
onPress={() => {
if (isRunning) return;
setInsoleType(type);
setActiveTaskId(null);
}}
disabled={isRunning}
style={{
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 2,
borderColor: selected ? '#2563EB' : '#E5E7EB',
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
fontFamily: semibold,
}}
>
{type}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* 2. Type handeling */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type handeling
</Text>
<TouchableOpacity
onPress={() => !isRunning && openPicker()}
disabled={isRunning}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
}}
>
<Text
style={{
fontSize: 16,
color: activeTaskId ? '#111827' : '#9CA3AF',
fontFamily: regular,
}}
>
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
</Text>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
</View>
{/* 3. Aantal zolen */}
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Aantal zolen
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'stretch',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
overflow: 'hidden',
}}
>
<TouchableOpacity
onPress={() => adjustInsoleCount(-1)}
disabled={isRunning || insoleCount <= 1}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
</Text>
</TouchableOpacity>
<TextInput
value={insoleCountText}
onChangeText={handleInsoleCountChange}
keyboardType="number-pad"
editable={!isRunning}
style={{
flex: 1,
textAlign: 'center',
fontSize: 22,
fontWeight: '600',
color: isRunning ? '#9CA3AF' : '#111827',
fontFamily: semibold,
paddingVertical: Platform.OS === 'android' ? 10 : 14,
paddingHorizontal: 0,
}}
/>
<TouchableOpacity
onPress={() => adjustInsoleCount(1)}
disabled={isRunning}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
+
</Text>
</TouchableOpacity>
</View>
</View>
{/* 4. Stopwatch display */}
<TouchableOpacity
onPress={() => {
if (!isRunning && canStart) handleStart();
else if (isRunning) {
if (isPaused) handleResume();
else handlePause();
}
}}
activeOpacity={canStart || isRunning ? 0.75 : 1}
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
backgroundColor: '#F9FAFB',
borderRadius: 24,
borderWidth: 1,
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
}}
>
<Text
style={{
fontSize: 64,
fontWeight: '600',
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
fontFamily: semibold,
letterSpacing: -2,
}}
>
{formatTime(elapsedTime)}
</Text>
{isRunning ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{
fontSize: 12,
color: isPaused ? '#D97706' : '#2563EB',
fontWeight: '500',
fontFamily: semibold,
}}
>
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
</Text>
</View>
) : canStart ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
>
Tik om te starten
</Text>
</View>
) : null}
</TouchableOpacity>
{/* 5. Knoppen */}
<View style={{ marginTop: 40 }}>
{!isRunning ? (
<TouchableOpacity
onPress={handleStart}
disabled={!canStart}
style={{
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Play
fill={canStart ? 'white' : '#9CA3AF'}
color={canStart ? 'white' : '#9CA3AF'}
size={24}
style={{ marginRight: 8 }}
/>
<Text
style={{
color: canStart ? 'white' : '#9CA3AF',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
Start Stopwatch
</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
onPress={handleStop}
style={{
backgroundColor: '#DC2626',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
marginBottom: 12,
}}
>
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
<Text
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
>
Stop & Opslaan
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDiscard}
style={{
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Text
style={{
color: discardPending ? '#ffffff' : '#6B7280',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
</Text>
</TouchableOpacity>
</>
)}
</View>
</ScrollView>
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
<Modal
visible={showPicker}
transparent
animationType="none"
onRequestClose={closePicker}
statusBarTranslucent
>
<View style={{ flex: 1 }}>
{/* Backdrop */}
<Pressable
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
onPress={closePicker}
/>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: SHEET_HEIGHT,
backgroundColor: '#ffffff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
transform: [{ translateY: slideAnim }],
}}
>
{/* Drag handle */}
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
</View>
{/* Sheet header */}
<View
style={{
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
>
Type handeling
</Text>
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
Kies een handeling
</Text>
</View>
{/* Task list */}
<ScrollView
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
showsVerticalScrollIndicator={false}
>
{filteredTasks.length === 0 ? (
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
<Text
style={{
fontSize: 15,
color: '#9CA3AF',
textAlign: 'center',
fontFamily: regular,
}}
>
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
Instellingen.
</Text>
</View>
) : (
filteredTasks.map((task: any) => {
const selected = activeTaskId === task.id;
return (
<TouchableOpacity
key={task.id}
onPress={() => {
setActiveTaskId(task.id);
closePicker();
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 18,
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
color: selected ? '#2563EB' : '#374151',
fontFamily: selected ? semibold : regular,
}}
>
{task.name}
</Text>
{selected && <Check size={20} color="#2563EB" />}
</TouchableOpacity>
);
})
)}
</ScrollView>
</Animated.View>
</View>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,574 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof ALL_TYPES)[number];
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
};
function TypeToggle({
type,
selected,
onPress,
}: {
type: InsoleType;
selected: boolean;
onPress: () => void;
}) {
const c = TYPE_COLORS[type];
return (
<TouchableOpacity
onPress={onPress}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 2,
borderColor: selected ? c.border : '#E5E7EB',
backgroundColor: selected ? c.bg : '#F9FAFB',
}}
>
{selected && <Check size={13} color={c.text} />}
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: selected ? c.text : '#9CA3AF',
fontFamily: 'Inter_600SemiBold',
}}
>
{type}
</Text>
</TouchableOpacity>
);
}
function TypeBadge({ type }: { type: InsoleType }) {
const c = TYPE_COLORS[type];
return (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
}}
>
<Text
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
>
{type}
</Text>
</View>
);
}
export default function TasksScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [newTaskName, setNewTaskName] = useState('');
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const addTaskMutation = useMutation({
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
const res = await fetch(`${BASE_URL}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to add task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setNewTaskName('');
setNewTaskTypes(['Kurk', 'Berk', '3D']);
},
});
const updateTaskMutation = useMutation({
mutationFn: async ({
id,
name,
insole_types,
}: {
id: number;
name: string;
insole_types: string[];
}) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to update task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setEditingId(null);
},
});
const deleteTaskMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
const toggleNewType = (type: InsoleType) => {
setNewTaskTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const toggleEditType = (type: InsoleType) => {
setEditingTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const handleAddTask = () => {
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
};
const handleStartEdit = (task: any) => {
setEditingId(task.id);
setEditingName(task.name);
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
};
const handleConfirmEdit = () => {
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
updateTaskMutation.mutate({
id: editingId,
name: editingName.trim(),
insole_types: editingTypes,
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
setEditingTypes([]);
};
const handleDelete = (task: any) => {
Alert.alert(
'Taak verwijderen',
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Verwijderen',
style: 'destructive',
onPress: () => deleteTaskMutation.mutate(task.id),
},
]
);
};
if (!fontsLoaded && !fontError) return null;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#ffffff' }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={{ paddingTop: insets.top, flex: 1 }}>
{/* Header */}
<View
style={{
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Instellingen
</Text>
<Text
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
>
Beheer handelingen per zooltype
</Text>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
{/* Add New Task */}
<View
style={{
backgroundColor: '#F9FAFB',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: '#E5E7EB',
marginBottom: 28,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Nieuwe handeling toevoegen
</Text>
{/* Name input */}
<TextInput
value={newTaskName}
onChangeText={setNewTaskName}
placeholder="Naam van de stap, bijv. Leerrand"
placeholderTextColor="#9CA3AF"
returnKeyType="done"
onSubmitEditing={handleAddTask}
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 11,
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
marginBottom: 12,
}}
/>
{/* Insole type toggles */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={newTaskTypes.includes(type)}
onPress={() => toggleNewType(type)}
/>
))}
</View>
<TouchableOpacity
onPress={handleAddTask}
disabled={
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
}
style={{
backgroundColor:
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
}}
>
{addTaskMutation.isPending ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Plus
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
size={18}
/>
<Text
style={{
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
fontSize: 15,
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
}}
>
Stap toevoegen
</Text>
</>
)}
</TouchableOpacity>
</View>
{/* Task List */}
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Huidige stappen ({tasks.length})
</Text>
{isLoading ? (
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
) : tasks.length === 0 ? (
<View style={{ alignItems: 'center', marginTop: 40 }}>
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
Nog geen stappen. Voeg er een toe hierboven.
</Text>
</View>
) : (
tasks.map((task: any) => {
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
const isEditing = editingId === task.id;
return (
<View
key={task.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
padding: 14,
marginBottom: 10,
}}
>
{isEditing ? (
<>
{/* Edit name */}
<TextInput
value={editingName}
onChangeText={setEditingName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleConfirmEdit}
style={{
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
paddingBottom: 8,
marginBottom: 12,
}}
/>
{/* Edit insole types */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={editingTypes.includes(type)}
onPress={() => toggleEditType(type)}
/>
))}
</View>
{/* Confirm / Cancel */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={handleConfirmEdit}
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
style={{
flex: 1,
backgroundColor: '#DCFCE7',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
{updateTaskMutation.isPending ? (
<ActivityIndicator color="#16A34A" size="small" />
) : (
<>
<Check size={16} color="#16A34A" />
<Text
style={{
color: '#16A34A',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Opslaan
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleCancelEdit}
style={{
flex: 1,
backgroundColor: '#F3F4F6',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
<X size={16} color="#6B7280" />
<Text
style={{
color: '#6B7280',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Annuleren
</Text>
</TouchableOpacity>
</View>
</>
) : (
<>
{/* Task name + actions */}
<View
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
>
<Text
style={{
flex: 1,
fontSize: 15,
color: '#374151',
fontFamily: 'Inter_400Regular',
}}
>
{task.name}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => handleStartEdit(task)}
style={{
backgroundColor: '#EFF6FF',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Pencil color="#2563EB" size={16} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDelete(task)}
disabled={deleteTaskMutation.isPending}
style={{
backgroundColor: '#FEF2F2',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Trash2 color="#DC2626" size={16} />
</TouchableOpacity>
</View>
</View>
{/* Insole type badges */}
<View style={{ flexDirection: 'row', gap: 6 }}>
{types.map((type) => (
<TypeBadge key={type} type={type} />
))}
</View>
</>
)}
</View>
);
})
)}
</ScrollView>
</View>
</KeyboardAvoidingView>
);
}