diff --git a/backend/package.json b/backend/package.json index 7a9104b..4682ecd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "build": "tsc", "start": "node dist/src/index.js", "schema:index": "ts-node scripts/generate-schema-index.ts", - "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts" + "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", + "members:import:csv": "tsx scripts/import-members-csv.ts" }, "repository": { "type": "git", diff --git a/mobile/app.json b/mobile/app.json index 80f0da4..efb3824 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -1,17 +1,32 @@ { "expo": { - "name": "mobile", - "slug": "mobile", + "name": "FEDEO", + "slug": "fedeo-mobile", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "mobile", + "scheme": "fedeo", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "de.fedeo.mobile", + "buildNumber": "1", + "infoPlist": { + "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", + "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", + "NSPhotoLibraryAddUsageDescription": "Die App benötigt Zugriff, um Fotos für Uploads zu speichern und zu verwenden.", + "NSBluetoothAlwaysUsageDescription": "Bluetooth wird benötigt, um den Nimbot M2 Etikettendrucker zu verbinden.", + "NSBluetoothPeripheralUsageDescription": "Bluetooth wird benötigt, um mit dem Nimbot M2 zu kommunizieren." + } }, "android": { + "package": "de.fedeo.mobile", + "permissions": [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_FINE_LOCATION" + ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -38,7 +53,8 @@ "backgroundColor": "#000000" } } - ] + ], + "react-native-ble-plx" ], "experiments": { "typedRoutes": true, diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index a1088ee..6c067d0 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -31,16 +31,23 @@ export default function TabLayout() { }}> , + }} + /> + , }} /> , + title: 'Projekte', + tabBarIcon: ({ color }) => , }} /> , }} /> + , + }} + /> ); } diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx index 91e6021..c911d62 100644 --- a/mobile/app/(tabs)/explore.tsx +++ b/mobile/app/(tabs)/explore.tsx @@ -1,187 +1,139 @@ -import { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { router } from 'expo-router'; -import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider'; - -const PRIMARY = '#69c350'; - -export default function AccountScreen() { - const { token, user, tenants, activeTenantId, activeTenant, switchTenant, logout } = useAuth(); - const storageInfo = useTokenStorageInfo(); - - const [switchingTenantId, setSwitchingTenantId] = useState(null); - const [switchError, setSwitchError] = useState(null); - - const userId = String(user?.id || 'unbekannt'); - - async function onSwitchTenant(tenantId: number) { - setSwitchingTenantId(tenantId); - setSwitchError(null); - - try { - await switchTenant(tenantId); - } catch (err) { - setSwitchError(err instanceof Error ? err.message : 'Tenant konnte nicht gewechselt werden.'); - } finally { - setSwitchingTenantId(null); - } - } +const ITEMS = [ + { + key: 'account', + title: 'Konto', + subtitle: 'Session, Tenant-Wechsel, Logout', + href: '/more/account', + }, + { + key: 'settings', + title: 'Einstellungen', + subtitle: 'Server-Instanz verwalten', + href: '/more/settings', + }, + { + key: 'wiki', + title: 'Wiki', + subtitle: 'Wissen und Dokumentation', + href: '/more/wiki', + }, + { + key: 'customers', + title: 'Kunden', + subtitle: '', + href: '/more/customers', + }, + { + key: 'plants', + title: 'Objekte', + subtitle: '', + href: '/more/plants', + }, + { + key: 'inventory', + title: 'Kundeninventar', + subtitle: 'Inventar und Scanner', + href: '/more/inventory', + }, + { + key: 'nimbot', + title: 'Nimbot M2', + subtitle: 'Drucker verbinden', + href: '/more/nimbot', + }, +]; +export default function MoreScreen() { return ( - - Konto - Session-Infos und Tenant-Wechsel. + + + + Funktionen + Weitere Bereiche und Einstellungen. + - - Token vorhanden - {token ? 'ja' : 'nein'} - - User ID - {userId} - - Aktiver Tenant - {activeTenant ? `${activeTenant.name} (#${activeTenantId})` : 'nicht gesetzt'} - - Storage Modus - {storageInfo.mode} - - - - Tenant wechseln - - {switchError ? {switchError} : null} - - {tenants.map((tenant) => { - const tenantId = Number(tenant.id); - const isActive = tenantId === activeTenantId; - const isSwitching = switchingTenantId === tenantId; - - return ( - onSwitchTenant(tenantId)} - disabled={isActive || switchingTenantId !== null}> - - - {tenant.name} - - ID: {tenantId} - - - {isActive ? 'Aktiv' : isSwitching ? 'Wechsel...' : 'Wechseln'} - - - ); - })} - - - - Logout - - + {ITEMS.map((item, index) => ( + router.push(item.href as any)}> + + {item.title} + {item.subtitle} + + + {index < ITEMS.length - 1 ? : null} + + ))} + + ); } const styles = StyleSheet.create({ - container: { - flexGrow: 1, - backgroundColor: '#f9fafb', - padding: 16, - gap: 12, - }, - title: { - fontSize: 24, - fontWeight: '700', - color: '#111827', - }, - subtitle: { - color: '#6b7280', - }, - card: { + screen: { + flex: 1, backgroundColor: '#ffffff', - borderRadius: 12, - padding: 14, - borderWidth: 1, - borderColor: '#e5e7eb', - gap: 8, + }, + list: { + flex: 1, + backgroundColor: '#ffffff', + }, + listContent: { + paddingBottom: 24, + }, + section: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 10, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', }, sectionTitle: { - fontSize: 16, - fontWeight: '600', color: '#111827', - marginBottom: 2, + fontSize: 17, + fontWeight: '700', }, - label: { - fontSize: 12, - color: '#6b7280', - textTransform: 'uppercase', - }, - value: { - fontSize: 16, - color: '#111827', - marginBottom: 2, - }, - error: { - color: '#dc2626', - marginBottom: 4, - }, - tenantButton: { - borderRadius: 10, - padding: 12, - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - tenantButtonActive: { - borderColor: PRIMARY, - backgroundColor: '#eff9ea', - }, - tenantButtonDisabled: { - opacity: 0.6, - }, - tenantInfo: { - flex: 1, - minWidth: 0, - paddingRight: 10, - }, - tenantName: { - fontSize: 15, - fontWeight: '600', - color: '#111827', - flexShrink: 1, - }, - tenantMeta: { + sectionSubtitle: { color: '#6b7280', + fontSize: 13, marginTop: 3, }, - tenantActionWrap: { - minWidth: 84, - alignItems: 'flex-end', - }, - tenantAction: { - color: PRIMARY, - fontWeight: '600', - textAlign: 'right', - }, - logoutButton: { - marginTop: 4, - backgroundColor: '#dc2626', - minHeight: 44, - borderRadius: 10, + row: { + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: '#ffffff', + position: 'relative', + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', + justifyContent: 'space-between', + gap: 10, }, - logoutText: { - color: '#ffffff', + rowMain: { + flex: 1, + minWidth: 0, + }, + rowTitle: { + color: '#111827', + fontSize: 15, fontWeight: '600', - fontSize: 16, + }, + rowSubtitle: { + color: '#6b7280', + fontSize: 13, + marginTop: 2, + }, + rowArrow: { + color: '#9ca3af', + fontSize: 24, + lineHeight: 24, + }, + rowDivider: { + position: 'absolute', + left: 16, + right: 16, + bottom: 0, + height: 1, + backgroundColor: '#e5e7eb', }, }); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 26d4337..5f813d7 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,416 +1,258 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - ActivityIndicator, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - RefreshControl, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { router } from 'expo-router'; -import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api'; +import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; -const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; const PRIMARY = '#69c350'; -function normalizeStatus(status: unknown): TaskStatus { - if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status; +type DashboardData = { + tasks: Task[]; + openTasks: number; + inProgressTasks: number; + activeTimeStart: string | null; + pendingSubmissions: number; + todayMinutes: number; +}; + +function normalizeTaskStatus(value: unknown): 'Offen' | 'In Bearbeitung' | 'Abgeschlossen' { + if (value === 'In Bearbeitung') return 'In Bearbeitung'; + if (value === 'Abgeschlossen') return 'Abgeschlossen'; return 'Offen'; } -function getTaskAssigneeId(task: Task): string | null { - return (task.userId || task.user_id || task.profile || null) as string | null; +function formatMinutes(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${h}h ${String(m).padStart(2, '0')}m`; } -export default function TasksScreen() { - const { token, user, activeTenantId } = useAuth(); +function formatDateTime(value: string | null): string { + if (!value) return '-'; + return new Date(value).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function DashboardScreen() { + const { token, user } = useAuth(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [saving, setSaving] = useState(false); - const [updatingTaskId, setUpdatingTaskId] = useState(null); const [error, setError] = useState(null); - - const [tasks, setTasks] = useState([]); - const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]); - - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle'); - const [showCompleted, setShowCompleted] = useState(false); - - const [showSearchPanel, setShowSearchPanel] = useState(false); - const [showFilterPanel, setShowFilterPanel] = useState(false); - - const [createModalOpen, setCreateModalOpen] = useState(false); - const [createError, setCreateError] = useState(null); - const [newTaskName, setNewTaskName] = useState(''); - const [newTaskDescription, setNewTaskDescription] = useState(''); + const [data, setData] = useState({ + tasks: [], + openTasks: 0, + inProgressTasks: 0, + activeTimeStart: null, + pendingSubmissions: 0, + todayMinutes: 0, + }); const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]); - const filteredTasks = useMemo(() => { - const needle = search.trim().toLowerCase(); - - return tasks - .filter((task) => { - const status = normalizeStatus(task.categorie); - if (!showCompleted && status === 'Abgeschlossen') return false; - const statusMatch = statusFilter === 'Alle' || status === statusFilter; - const textMatch = - !needle || - [task.name, task.description, task.categorie].some((value) => - String(value || '').toLowerCase().includes(needle) - ); - return statusMatch && textMatch; - }) - .sort((a, b) => Number(a.id) - Number(b.id)); - }, [search, showCompleted, statusFilter, tasks]); - - function getAssigneeLabel(task: Task): string { - const assigneeId = getTaskAssigneeId(task); - if (!assigneeId) return '-'; - return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId; - } - - const loadTasks = useCallback( + const loadDashboard = useCallback( async (showSpinner = true) => { - if (!token) return; - + if (!token || !currentUserId) return; if (showSpinner) setLoading(true); setError(null); try { - const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]); - setTasks(taskRows || []); - setProfiles( - (profileRows || []) - .map((profile) => { - const id = profile.user_id || (profile.id ? String(profile.id) : null); - const label = profile.full_name || profile.fullName || profile.email || id; - return id ? { id: String(id), label: String(label || id) } : null; - }) - .filter((value): value is { id: string; label: string } => Boolean(value)) - ); + const [taskRows, spans] = await Promise.all([ + fetchTasks(token), + fetchStaffTimeSpans(token, currentUserId), + ]); + + const tasks = taskRows || []; + const openTasks = tasks.filter((task) => normalizeTaskStatus(task.categorie) === 'Offen').length; + const inProgressTasks = tasks.filter( + (task) => normalizeTaskStatus(task.categorie) === 'In Bearbeitung' + ).length; + + const activeTime = spans.find((span) => !span.stopped_at) || null; + const pendingSubmissions = spans.filter( + (span) => (span.state === 'draft' || span.state === 'factual') && !!span.stopped_at + ).length; + + const today = new Date(); + const todayIso = today.toISOString().slice(0, 10); + const todayMinutes = spans + .filter((span) => span.started_at?.slice(0, 10) === todayIso) + .reduce((sum, span) => sum + (span.duration_minutes || 0), 0); + + setData({ + tasks, + openTasks, + inProgressTasks, + activeTimeStart: activeTime?.started_at || null, + pendingSubmissions, + todayMinutes, + }); } catch (err) { - setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.'); + setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.'); } finally { setLoading(false); setRefreshing(false); } }, - [token] + [currentUserId, token] ); useEffect(() => { - if (!token || !activeTenantId) return; - void loadTasks(true); - }, [token, activeTenantId, loadTasks]); + if (!token || !currentUserId) return; + void loadDashboard(true); + }, [currentUserId, loadDashboard, token]); async function onRefresh() { - if (!token) return; setRefreshing(true); - await loadTasks(false); - } - - function closeCreateModal() { - setCreateModalOpen(false); - setCreateError(null); - setNewTaskName(''); - setNewTaskDescription(''); - } - - async function onCreateTask() { - if (!token) return; - - const name = newTaskName.trim(); - if (!name) { - setCreateError('Bitte einen Aufgabennamen eingeben.'); - return; - } - - setSaving(true); - setCreateError(null); - - try { - await createTask(token, { - name, - description: newTaskDescription.trim() || null, - categorie: 'Offen', - userId: currentUserId, - }); - - closeCreateModal(); - await loadTasks(false); - } catch (err) { - setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.'); - } finally { - setSaving(false); - } - } - - async function setTaskStatus(task: Task, status: TaskStatus) { - if (!token || !task?.id) return; - if (normalizeStatus(task.categorie) === status) return; - - setUpdatingTaskId(Number(task.id)); - setError(null); - - try { - await updateTask(token, Number(task.id), { categorie: status }); - setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item))); - } catch (err) { - setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.'); - } finally { - setUpdatingTaskId(null); - } + await loadDashboard(false); } return ( - - }> - - setShowSearchPanel((prev) => !prev)}> - Suche - - setShowFilterPanel((prev) => !prev)}> - Filter - + }> + {error ? {error} : null} + + {loading ? ( + + + Dashboard wird geladen... + ) : null} - {showSearchPanel ? ( - - + {!loading ? ( + <> + + + Aktive Zeit + + {data.activeTimeStart ? `Seit ${formatDateTime(data.activeTimeStart)}` : 'Nicht aktiv'} + + - ) : null} - {showFilterPanel ? ( - - - {(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => ( - setStatusFilter(status)}> - - {status} - - - ))} - setShowCompleted((prev) => !prev)}> - - Abgeschlossene anzeigen - + + + Offene Aufgaben + {data.openTasks} + + + In Bearbeitung + {data.inProgressTasks} + + + + + + Heute erfasst + {formatMinutes(data.todayMinutes)} + + + Zum Einreichen + {data.pendingSubmissions} + + + + + Schnellzugriff + + router.push('/(tabs)/tasks')}> + Aufgaben + + router.push('/(tabs)/projects')}> + Projekten + + router.push('/(tabs)/time')}> + Zeiten + + router.push('/more/inventory?action=scan')}> + Inventar Scan - ) : null} - - {error ? {error} : null} - - {loading ? ( - - - Aufgaben werden geladen... - - ) : null} - - {!loading && filteredTasks.length === 0 ? ( - Keine Aufgaben gefunden. - ) : null} - - {!loading && - filteredTasks.map((task) => { - const status = normalizeStatus(task.categorie); - const isUpdating = updatingTaskId === Number(task.id); - - return ( - - - - {task.name} - - {status} - - - {task.description ? ( - - {task.description} - - ) : null} - - Zuweisung: {getAssigneeLabel(task)} - - - {STATUSES.map((nextStatus) => ( - setTaskStatus(task, nextStatus)} - disabled={isUpdating || nextStatus === status}> - {nextStatus} - - ))} - - - ); - })} - - - setCreateModalOpen(true)}> - + - - - - - - - Neue Aufgabe - - - - - {createError ? {createError} : null} - - - - Abbrechen - - - {saving ? 'Speichere...' : 'Anlegen'} - - - - - - - + + ) : null} + ); } const styles = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: '#f9fafb', - }, container: { padding: 16, gap: 12, - paddingBottom: 96, + backgroundColor: '#f9fafb', }, - topActions: { + row: { flexDirection: 'row', - gap: 8, + gap: 10, }, - topActionButton: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 8, + metricCard: { + flex: 1, backgroundColor: '#ffffff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 12, + gap: 6, }, - topActionButtonActive: { + metricCardPrimary: { borderColor: PRIMARY, backgroundColor: '#eff9ea', }, - topActionText: { - color: '#374151', - fontWeight: '600', + metricLabel: { + color: '#6b7280', + fontSize: 12, + textTransform: 'uppercase', }, - topActionTextActive: { + metricValue: { + color: '#111827', + fontSize: 22, + fontWeight: '700', + }, + metricLabelPrimary: { color: '#3d7a30', + fontSize: 12, + textTransform: 'uppercase', }, - panel: { + metricValuePrimary: { + color: '#2f5f24', + fontSize: 18, + fontWeight: '700', + }, + quickActionsCard: { backgroundColor: '#ffffff', borderRadius: 12, - padding: 12, borderWidth: 1, borderColor: '#e5e7eb', + padding: 12, + gap: 10, }, - input: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 15, + quickActionsTitle: { color: '#111827', - backgroundColor: '#ffffff', + fontSize: 15, + fontWeight: '600', }, - inputMultiline: { - minHeight: 72, - textAlignVertical: 'top', - }, - filterRow: { + quickActionsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, }, - filterChip: { - borderRadius: 999, - borderWidth: 1, - borderColor: '#d1d5db', - paddingHorizontal: 10, - paddingVertical: 6, - backgroundColor: '#ffffff', + quickActionButton: { + minWidth: 120, + minHeight: 40, + borderRadius: 10, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', }, - filterChipActive: { - borderColor: PRIMARY, - backgroundColor: '#eff9ea', - }, - filterChipText: { - color: '#374151', - fontSize: 13, - fontWeight: '500', - }, - filterChipTextActive: { - color: '#3d7a30', + quickActionText: { + color: '#ffffff', + fontWeight: '600', }, error: { color: '#dc2626', @@ -424,144 +266,4 @@ const styles = StyleSheet.create({ loadingText: { color: '#6b7280', }, - empty: { - color: '#6b7280', - textAlign: 'center', - paddingVertical: 16, - }, - taskCard: { - backgroundColor: '#ffffff', - borderRadius: 12, - padding: 12, - gap: 8, - borderWidth: 1, - borderColor: '#e5e7eb', - }, - taskHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - gap: 8, - }, - taskTitle: { - flex: 1, - color: '#111827', - fontSize: 16, - fontWeight: '600', - }, - statusBadge: { - color: '#3d7a30', - backgroundColor: '#eff9ea', - borderRadius: 999, - paddingHorizontal: 8, - paddingVertical: 4, - fontSize: 12, - overflow: 'hidden', - }, - taskDescription: { - color: '#374151', - fontSize: 14, - }, - taskMeta: { - color: '#6b7280', - fontSize: 12, - }, - actionRow: { - flexDirection: 'row', - gap: 8, - flexWrap: 'wrap', - }, - actionButton: { - borderRadius: 8, - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - paddingHorizontal: 10, - paddingVertical: 7, - }, - actionButtonActive: { - borderColor: PRIMARY, - backgroundColor: '#eff9ea', - }, - actionButtonText: { - color: '#1f2937', - fontSize: 12, - fontWeight: '500', - }, - fab: { - position: 'absolute', - right: 18, - bottom: 24, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: PRIMARY, - alignItems: 'center', - justifyContent: 'center', - elevation: 4, - shadowColor: '#111827', - shadowOpacity: 0.18, - shadowRadius: 10, - shadowOffset: { width: 0, height: 4 }, - }, - fabText: { - color: '#ffffff', - fontSize: 30, - lineHeight: 30, - fontWeight: '500', - marginTop: -1, - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - justifyContent: 'center', - padding: 16, - }, - modalKeyboardWrap: { - width: '100%', - }, - modalCard: { - backgroundColor: '#ffffff', - borderRadius: 14, - padding: 14, - gap: 10, - }, - modalTitle: { - fontSize: 18, - fontWeight: '700', - color: '#111827', - }, - modalActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 8, - marginTop: 2, - }, - secondaryButton: { - minHeight: 42, - borderRadius: 10, - paddingHorizontal: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#e5e7eb', - }, - secondaryButtonText: { - color: '#111827', - fontWeight: '600', - }, - primaryButton: { - minHeight: 42, - borderRadius: 10, - paddingHorizontal: 14, - backgroundColor: PRIMARY, - alignItems: 'center', - justifyContent: 'center', - }, - primaryButtonText: { - color: '#ffffff', - fontWeight: '600', - }, - buttonDisabled: { - opacity: 0.6, - }, }); diff --git a/mobile/app/(tabs)/projects.tsx b/mobile/app/(tabs)/projects.tsx new file mode 100644 index 0000000..2c2312b --- /dev/null +++ b/mobile/app/(tabs)/projects.tsx @@ -0,0 +1,533 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { router } from 'expo-router'; + +import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +function getProjectLine(project: Project): string { + if (project.projectNumber) return `${project.name} · ${project.projectNumber}`; + return project.name; +} + +function getActivePhaseLabel(project: Project): string { + const explicit = String(project.active_phase || '').trim(); + if (explicit) return explicit; + + const phases = Array.isArray(project.phases) ? project.phases : []; + const active = phases.find((phase: any) => phase?.active); + return String(active?.label || '').trim(); +} + +function isProjectCompletedByPhase(project: Project): boolean { + const phase = getActivePhaseLabel(project).toLowerCase(); + return phase === 'abgeschlossen'; +} + +export default function ProjectsScreen() { + const { token } = useAuth(); + + const [projects, setProjects] = useState([]); + const [customers, setCustomers] = useState([]); + const [plants, setPlants] = useState([]); + + const [search, setSearch] = useState(''); + const [showArchived, setShowArchived] = useState(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [createOpen, setCreateOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [createError, setCreateError] = useState(null); + const [nameInput, setNameInput] = useState(''); + const [projectNumberInput, setProjectNumberInput] = useState(''); + const [notesInput, setNotesInput] = useState(''); + const [selectedCustomerId, setSelectedCustomerId] = useState(null); + const [selectedPlantId, setSelectedPlantId] = useState(null); + + const [pickerMode, setPickerMode] = useState<'customer' | 'plant' | null>(null); + const [customerSearch, setCustomerSearch] = useState(''); + const [plantSearch, setPlantSearch] = useState(''); + + const filteredProjects = useMemo(() => { + const terms = search + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + + return projects.filter((project) => { + if (!showArchived && isProjectCompletedByPhase(project)) return false; + if (terms.length === 0) return true; + + const haystack = [ + project.name, + project.projectNumber, + project.notes, + project.customerRef, + project.active_phase, + getActivePhaseLabel(project), + ] + .map((value) => String(value || '').toLowerCase()) + .join(' '); + + return terms.every((term) => haystack.includes(term)); + }); + }, [projects, search, showArchived]); + + const selectedCustomerLabel = useMemo(() => { + if (!selectedCustomerId) return 'Kunde auswählen (optional)'; + const customer = customers.find((item) => Number(item.id) === selectedCustomerId); + return customer ? `${customer.name}${customer.customerNumber ? ` · ${customer.customerNumber}` : ''}` : `ID ${selectedCustomerId}`; + }, [customers, selectedCustomerId]); + + const selectedPlantLabel = useMemo(() => { + if (!selectedPlantId) return 'Objekt auswählen (optional)'; + const plant = plants.find((item) => Number(item.id) === selectedPlantId); + return plant ? plant.name : `ID ${selectedPlantId}`; + }, [plants, selectedPlantId]); + + const filteredCustomerOptions = useMemo(() => { + const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); + return customers + .filter((customer) => !customer.archived) + .filter((customer) => { + if (terms.length === 0) return true; + const haystack = [customer.name, customer.customerNumber] + .map((value) => String(value || '').toLowerCase()) + .join(' '); + return terms.every((term) => haystack.includes(term)); + }); + }, [customerSearch, customers]); + + const filteredPlantOptions = useMemo(() => { + const terms = plantSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); + return plants + .filter((plant) => !plant.archived) + .filter((plant) => { + if (terms.length === 0) return true; + const haystack = [plant.name, plant.description] + .map((value) => String(value || '').toLowerCase()) + .join(' '); + return terms.every((term) => haystack.includes(term)); + }); + }, [plantSearch, plants]); + + const loadProjects = useCallback(async (showSpinner = true) => { + if (!token) return; + if (showSpinner) setLoading(true); + setError(null); + + try { + const [projectRows, customerRows, plantRows] = await Promise.all([ + fetchProjects(token, true), + fetchCustomers(token, true), + fetchPlants(token, true), + ]); + setProjects(projectRows); + setCustomers(customerRows); + setPlants(plantRows); + } catch (err) { + setError(err instanceof Error ? err.message : 'Projekte konnten nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [token]); + + useEffect(() => { + if (!token) return; + void loadProjects(true); + }, [loadProjects, token]); + + async function onRefresh() { + setRefreshing(true); + await loadProjects(false); + } + + function closeCreateModal() { + setCreateOpen(false); + setCreateError(null); + setNameInput(''); + setProjectNumberInput(''); + setNotesInput(''); + setSelectedCustomerId(null); + setSelectedPlantId(null); + setCustomerSearch(''); + setPlantSearch(''); + setPickerMode(null); + } + + async function onCreateProject() { + if (!token) return; + + const name = nameInput.trim(); + if (!name) { + setCreateError('Bitte einen Projektnamen eingeben.'); + return; + } + + setSaving(true); + setCreateError(null); + + try { + await createProject(token, { + name, + projectNumber: projectNumberInput.trim() || null, + customer: selectedCustomerId, + plant: selectedPlantId, + notes: notesInput.trim() || null, + }); + closeCreateModal(); + await loadProjects(false); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Projekt konnte nicht erstellt werden.'); + } finally { + setSaving(false); + } + } + + return ( + + + + setShowArchived((prev) => !prev)}> + + Abgeschlossene anzeigen + + + + + }> + {error ? {error} : null} + + {loading ? ( + + + Projekte werden geladen... + + ) : null} + + {!loading && filteredProjects.length === 0 ? Keine Projekte gefunden. : null} + + {!loading && + filteredProjects.map((project) => ( + router.push(`/project/${project.id}`)}> + + {getProjectLine(project)} + {isProjectCompletedByPhase(project) ? Abgeschlossen : null} + + {getActivePhaseLabel(project) ? ( + Phase: {getActivePhaseLabel(project)} + ) : null} + {project.notes ? {String(project.notes)} : null} + + ))} + + + setCreateOpen(true)}> + + + + + + + + + Neues Projekt + + + + + + setPickerMode('customer')}> + {selectedCustomerLabel} + + + setPickerMode('plant')}> + {selectedPlantLabel} + + + + + {pickerMode === 'customer' ? ( + + Kunde auswählen + + + { + setSelectedCustomerId(null); + setPickerMode(null); + }}> + Keine Auswahl + + {filteredCustomerOptions.map((customer) => ( + { + setSelectedCustomerId(Number(customer.id)); + setPickerMode(null); + }}> + {customer.name} + + {customer.customerNumber ? `Nr. ${customer.customerNumber}` : `ID ${customer.id}`} + + + ))} + + + ) : null} + + {pickerMode === 'plant' ? ( + + Objekt auswählen + + + { + setSelectedPlantId(null); + setPickerMode(null); + }}> + Keine Auswahl + + {filteredPlantOptions.map((plant) => ( + { + setSelectedPlantId(Number(plant.id)); + setPickerMode(null); + }}> + {plant.name} + ID {plant.id} + + ))} + + + ) : null} + + {createError ? {createError} : null} + + + + Abbrechen + + + {saving ? 'Speichere...' : 'Anlegen'} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: '#ffffff' }, + searchWrap: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 10, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + backgroundColor: '#ffffff', + gap: 8, + }, + searchInput: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + backgroundColor: '#ffffff', + }, + list: { flex: 1, backgroundColor: '#ffffff' }, + listContent: { paddingBottom: 96 }, + row: { + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + backgroundColor: '#ffffff', + }, + rowHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' }, + archivedBadge: { + color: '#3d7a30', + fontSize: 11, + fontWeight: '600', + backgroundColor: '#eff9ea', + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 3, + overflow: 'hidden', + }, + rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 }, + phaseText: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + toggleButton: { + alignSelf: 'flex-start', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: '#ffffff', + }, + toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, + toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' }, + toggleButtonTextActive: { color: '#3d7a30' }, + loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 }, + loadingText: { color: '#6b7280' }, + empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 }, + error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 }, + fab: { + position: 'absolute', + right: 18, + bottom: 20, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#111827', + shadowOpacity: 0.25, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 5, + }, + fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(17, 24, 39, 0.45)', + justifyContent: 'center', + padding: 20, + }, + modalKeyboardWrap: { width: '100%' }, + modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 10 }, + modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' }, + multilineInput: { minHeight: 92, textAlignVertical: 'top' }, + selectButton: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#ffffff', + }, + selectButtonText: { color: '#111827', fontSize: 15 }, + inlinePickerBox: { + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 10, + padding: 10, + gap: 8, + backgroundColor: '#fafafa', + }, + inlinePickerTitle: { color: '#111827', fontSize: 14, fontWeight: '700' }, + pickerList: { maxHeight: 220 }, + pickerRow: { + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + marginBottom: 8, + backgroundColor: '#ffffff', + }, + pickerRowTitle: { color: '#111827', fontSize: 14, fontWeight: '600' }, + pickerRowMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 4 }, + secondaryButton: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + minHeight: 40, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { color: '#374151', fontWeight: '600' }, + primaryButton: { + borderRadius: 10, + minHeight: 40, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: PRIMARY, + }, + primaryButtonText: { color: '#ffffff', fontWeight: '700' }, + buttonDisabled: { opacity: 0.6 }, +}); diff --git a/mobile/app/(tabs)/tasks.tsx b/mobile/app/(tabs)/tasks.tsx new file mode 100644 index 0000000..5326089 --- /dev/null +++ b/mobile/app/(tabs)/tasks.tsx @@ -0,0 +1,582 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; + +import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; +const PRIMARY = '#69c350'; + +function normalizeStatus(status: unknown): TaskStatus { + if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status; + return 'Offen'; +} + +function getTaskAssigneeId(task: Task): string | null { + return (task.userId || task.user_id || task.profile || null) as string | null; +} + +export default function TasksScreen() { + const { token, user, activeTenantId } = useAuth(); + const params = useLocalSearchParams<{ action?: string | string[] }>(); + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [saving, setSaving] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [error, setError] = useState(null); + + const [tasks, setTasks] = useState([]); + const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]); + + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle'); + const [showCompleted, setShowCompleted] = useState(false); + + const [showSearchPanel, setShowSearchPanel] = useState(false); + const [showFilterPanel, setShowFilterPanel] = useState(false); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [createError, setCreateError] = useState(null); + const [newTaskName, setNewTaskName] = useState(''); + const [newTaskDescription, setNewTaskDescription] = useState(''); + const handledActionRef = useRef(null); + + const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]); + const incomingAction = useMemo(() => { + const raw = Array.isArray(params.action) ? params.action[0] : params.action; + return String(raw || '').toLowerCase(); + }, [params.action]); + + const filteredTasks = useMemo(() => { + const needle = search.trim().toLowerCase(); + + return tasks + .filter((task) => { + const status = normalizeStatus(task.categorie); + if (!showCompleted && status === 'Abgeschlossen') return false; + const statusMatch = statusFilter === 'Alle' || status === statusFilter; + const textMatch = + !needle || + [task.name, task.description, task.categorie].some((value) => + String(value || '').toLowerCase().includes(needle) + ); + return statusMatch && textMatch; + }) + .sort((a, b) => Number(a.id) - Number(b.id)); + }, [search, showCompleted, statusFilter, tasks]); + + function getAssigneeLabel(task: Task): string { + const assigneeId = getTaskAssigneeId(task); + if (!assigneeId) return '-'; + return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId; + } + + const loadTasks = useCallback( + async (showSpinner = true) => { + if (!token) return; + + if (showSpinner) setLoading(true); + setError(null); + + try { + const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]); + setTasks(taskRows || []); + setProfiles( + (profileRows || []) + .map((profile) => { + const id = profile.user_id || (profile.id ? String(profile.id) : null); + const label = profile.full_name || profile.fullName || profile.email || id; + return id ? { id: String(id), label: String(label || id) } : null; + }) + .filter((value): value is { id: string; label: string } => Boolean(value)) + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [token] + ); + + useEffect(() => { + if (!token || !activeTenantId) return; + void loadTasks(true); + }, [token, activeTenantId, loadTasks]); + + useEffect(() => { + if (incomingAction !== 'create') return; + if (handledActionRef.current === incomingAction) return; + handledActionRef.current = incomingAction; + setCreateModalOpen(true); + }, [incomingAction]); + + async function onRefresh() { + if (!token) return; + setRefreshing(true); + await loadTasks(false); + } + + function closeCreateModal() { + setCreateModalOpen(false); + setCreateError(null); + setNewTaskName(''); + setNewTaskDescription(''); + handledActionRef.current = null; + } + + async function onCreateTask() { + if (!token) return; + + const name = newTaskName.trim(); + if (!name) { + setCreateError('Bitte einen Aufgabennamen eingeben.'); + return; + } + + setSaving(true); + setCreateError(null); + + try { + await createTask(token, { + name, + description: newTaskDescription.trim() || null, + categorie: 'Offen', + userId: currentUserId, + }); + + closeCreateModal(); + await loadTasks(false); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.'); + } finally { + setSaving(false); + } + } + + async function setTaskStatus(task: Task, status: TaskStatus) { + if (!token || !task?.id) return; + if (normalizeStatus(task.categorie) === status) return; + + setUpdatingTaskId(Number(task.id)); + setError(null); + + try { + await updateTask(token, Number(task.id), { categorie: status }); + setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item))); + } catch (err) { + setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.'); + } finally { + setUpdatingTaskId(null); + } + } + + return ( + + }> + + setShowSearchPanel((prev) => !prev)}> + Suche + + setShowFilterPanel((prev) => !prev)}> + Filter + + + + {showSearchPanel ? ( + + + + ) : null} + + {showFilterPanel ? ( + + + {(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => ( + setStatusFilter(status)}> + + {status} + + + ))} + setShowCompleted((prev) => !prev)}> + + Abgeschlossene anzeigen + + + + + ) : null} + + {error ? {error} : null} + + {loading ? ( + + + Aufgaben werden geladen... + + ) : null} + + {!loading && filteredTasks.length === 0 ? ( + Keine Aufgaben gefunden. + ) : null} + + {!loading && + filteredTasks.map((task) => { + const status = normalizeStatus(task.categorie); + const isUpdating = updatingTaskId === Number(task.id); + + return ( + + + + {task.name} + + {status} + + + {task.description ? ( + + {task.description} + + ) : null} + + Zuweisung: {getAssigneeLabel(task)} + + + {STATUSES.map((nextStatus) => ( + setTaskStatus(task, nextStatus)} + disabled={isUpdating || nextStatus === status}> + {nextStatus} + + ))} + + + ); + })} + + + setCreateModalOpen(true)}> + + + + + + + + + Neue Aufgabe + + + + + {createError ? {createError} : null} + + + + Abbrechen + + + {saving ? 'Speichere...' : 'Anlegen'} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#f9fafb', + }, + container: { + padding: 16, + gap: 12, + paddingBottom: 96, + }, + topActions: { + flexDirection: 'row', + gap: 8, + }, + topActionButton: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: '#ffffff', + }, + topActionButtonActive: { + borderColor: PRIMARY, + backgroundColor: '#eff9ea', + }, + topActionText: { + color: '#374151', + fontWeight: '600', + }, + topActionTextActive: { + color: '#3d7a30', + }, + panel: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + }, + input: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + backgroundColor: '#ffffff', + }, + inputMultiline: { + minHeight: 72, + textAlignVertical: 'top', + }, + filterRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + filterChip: { + borderRadius: 999, + borderWidth: 1, + borderColor: '#d1d5db', + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: '#ffffff', + }, + filterChipActive: { + borderColor: PRIMARY, + backgroundColor: '#eff9ea', + }, + filterChipText: { + color: '#374151', + fontSize: 13, + fontWeight: '500', + }, + filterChipTextActive: { + color: '#3d7a30', + }, + error: { + color: '#dc2626', + fontSize: 13, + }, + loadingBox: { + padding: 16, + alignItems: 'center', + gap: 8, + }, + loadingText: { + color: '#6b7280', + }, + empty: { + color: '#6b7280', + textAlign: 'center', + paddingVertical: 16, + }, + taskCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + gap: 8, + borderWidth: 1, + borderColor: '#e5e7eb', + }, + taskHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 8, + }, + taskTitle: { + flex: 1, + color: '#111827', + fontSize: 16, + fontWeight: '600', + }, + statusBadge: { + color: '#3d7a30', + backgroundColor: '#eff9ea', + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 4, + fontSize: 12, + overflow: 'hidden', + }, + taskDescription: { + color: '#374151', + fontSize: 14, + }, + taskMeta: { + color: '#6b7280', + fontSize: 12, + }, + actionRow: { + flexDirection: 'row', + gap: 8, + flexWrap: 'wrap', + }, + actionButton: { + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#ffffff', + paddingHorizontal: 10, + paddingVertical: 7, + }, + actionButtonActive: { + borderColor: PRIMARY, + backgroundColor: '#eff9ea', + }, + actionButtonText: { + color: '#1f2937', + fontSize: 12, + fontWeight: '500', + }, + fab: { + position: 'absolute', + right: 18, + bottom: 24, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + elevation: 4, + shadowColor: '#111827', + shadowOpacity: 0.18, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + }, + fabText: { + color: '#ffffff', + fontSize: 30, + lineHeight: 30, + fontWeight: '500', + marginTop: -1, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + justifyContent: 'center', + padding: 16, + }, + modalKeyboardWrap: { + width: '100%', + }, + modalCard: { + backgroundColor: '#ffffff', + borderRadius: 14, + padding: 14, + gap: 10, + }, + modalTitle: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 8, + marginTop: 2, + }, + secondaryButton: { + minHeight: 42, + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#e5e7eb', + }, + secondaryButtonText: { + color: '#111827', + fontWeight: '600', + }, + primaryButton: { + minHeight: 42, + borderRadius: 10, + paddingHorizontal: 14, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + color: '#ffffff', + fontWeight: '600', + }, + buttonDisabled: { + opacity: 0.6, + }, +}); diff --git a/mobile/app/(tabs)/time.tsx b/mobile/app/(tabs)/time.tsx index 3ca0907..0c135bc 100644 --- a/mobile/app/(tabs)/time.tsx +++ b/mobile/app/(tabs)/time.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; import { createStaffTimeEvent, @@ -47,6 +48,7 @@ function getTypeLabel(type: string): string { export default function TimeTrackingScreen() { const { token, user } = useAuth(); + const params = useLocalSearchParams<{ action?: string | string[] }>(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -55,8 +57,13 @@ export default function TimeTrackingScreen() { const [error, setError] = useState(null); const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]); + const handledActionRef = useRef(null); const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]); + const incomingAction = useMemo(() => { + const raw = Array.isArray(params.action) ? params.action[0] : params.action; + return String(raw || '').toLowerCase(); + }, [params.action]); const load = useCallback( async (showSpinner = true) => { @@ -87,7 +94,7 @@ export default function TimeTrackingScreen() { await load(false); } - async function onStart() { + const onStart = useCallback(async () => { if (!token || !currentUserId) return; setActionLoading(true); setError(null); @@ -105,9 +112,9 @@ export default function TimeTrackingScreen() { } finally { setActionLoading(false); } - } + }, [currentUserId, token, load]); - async function onStop() { + const onStop = useCallback(async () => { if (!token || !currentUserId || !active) return; setActionLoading(true); setError(null); @@ -124,7 +131,7 @@ export default function TimeTrackingScreen() { } finally { setActionLoading(false); } - } + }, [active, currentUserId, token, load]); async function onSubmit(entry: StaffTimeSpan) { if (!token || !entry.eventIds?.length) return; @@ -141,6 +148,57 @@ export default function TimeTrackingScreen() { } } + const onSubmitAll = useCallback(async () => { + if (!token) return; + + const submitCandidates = entries.filter( + (entry) => (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at && !!entry.eventIds?.length + ); + + if (submitCandidates.length === 0) return; + + setActionLoading(true); + setError(null); + + try { + for (const entry of submitCandidates) { + await submitStaffTime(token, entry.eventIds); + } + await load(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.'); + } finally { + setActionLoading(false); + } + }, [entries, load, token]); + + useEffect(() => { + if (!token || !currentUserId) return; + if (!incomingAction) return; + if (handledActionRef.current === incomingAction) return; + + if (incomingAction === 'start' && !active) { + handledActionRef.current = incomingAction; + void onStart().finally(() => router.replace('/(tabs)/time')); + return; + } + + if (incomingAction === 'stop' && active) { + handledActionRef.current = incomingAction; + void onStop().finally(() => router.replace('/(tabs)/time')); + return; + } + + if (incomingAction === 'submit') { + handledActionRef.current = incomingAction; + void onSubmitAll().finally(() => router.replace('/(tabs)/time')); + return; + } + + handledActionRef.current = incomingAction; + void router.replace('/(tabs)/time'); + }, [active, currentUserId, incomingAction, onStart, onStop, onSubmitAll, token]); + return ( Aktive Zeit - {active ? `Laeuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'} + {active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'} @@ -179,7 +237,7 @@ export default function TimeTrackingScreen() { ) : null} - {!loading && entries.length === 0 ? Keine Zeiteintraege vorhanden. : null} + {!loading && entries.length === 0 ? Keine Zeiteinträge vorhanden. : null} {!loading && entries.map((entry) => { @@ -194,7 +252,7 @@ export default function TimeTrackingScreen() { Start: {formatDateTime(entry.started_at)} - Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'laeuft...'} + Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'läuft...'} Dauer: {formatDuration(entry.duration_minutes)} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1c6462f..55eadf1 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,8 +10,108 @@ export default function RootLayout() { - + + + + + + + + + + + diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx index c5538af..87519ad 100644 --- a/mobile/app/login.tsx +++ b/mobile/app/login.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; -import { Redirect, router } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { Redirect, router, useFocusEffect } from 'expo-router'; import { ActivityIndicator, KeyboardAvoidingView, + Modal, Platform, Pressable, StyleSheet, @@ -11,22 +12,100 @@ import { View, } from 'react-native'; -import { API_BASE_URL } from '@/src/config/env'; +import { + getApiBaseUrlSync, + hydrateApiBaseUrl, + isServerSetupDone, + markServerSetupDone, + setApiBaseUrl as persistApiBaseUrl, +} from '@/src/lib/server-config'; import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider'; +const PRIMARY = '#69c350'; + +function isValidServerUrl(value: string): boolean { + return /^https?:\/\/.+/i.test(value.trim()); +} + export default function LoginScreen() { const { token, requiresTenantSelection, login } = useAuth(); const storageInfo = useTokenStorageInfo(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync()); + const [serverInput, setServerInput] = useState(getApiBaseUrlSync()); + const [showServerModal, setShowServerModal] = useState(false); + const [isServerSetupRequired, setIsServerSetupRequired] = useState(false); + const [isServerSaving, setIsServerSaving] = useState(false); + const [serverError, setServerError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + useFocusEffect( + useCallback(() => { + let active = true; + + async function refreshApiBase() { + const current = await hydrateApiBaseUrl(); + const setupDone = await isServerSetupDone(); + if (!active) return; + setApiBaseUrl(current); + setServerInput(current); + setIsServerSetupRequired(!setupDone); + setShowServerModal(!setupDone); + } + + void refreshApiBase(); + return () => { + active = false; + }; + }, []) + ); + if (token) { return ; } + async function applyDefaultServer() { + setIsServerSaving(true); + setServerError(null); + + try { + await markServerSetupDone(); + setIsServerSetupRequired(false); + setShowServerModal(false); + setApiBaseUrl(getApiBaseUrlSync()); + } catch (err) { + setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.'); + } finally { + setIsServerSaving(false); + } + } + + async function saveCustomServer() { + setServerError(null); + const value = serverInput.trim(); + + if (!isValidServerUrl(value)) { + setServerError('Bitte eine gültige URL mit http:// oder https:// eingeben.'); + return; + } + + setIsServerSaving(true); + try { + const normalized = await persistApiBaseUrl(value); + setApiBaseUrl(normalized); + setServerInput(normalized); + setIsServerSetupRequired(false); + setShowServerModal(false); + } catch (err) { + setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.'); + } finally { + setIsServerSaving(false); + } + } + async function onSubmit() { setIsSubmitting(true); setError(null); @@ -76,15 +155,57 @@ export default function LoginScreen() { + disabled={isSubmitting || isServerSetupRequired || !email || !password}> {isSubmitting ? : Anmelden} + setShowServerModal(true)}> + Eigenen Server festlegen + + - API: {API_BASE_URL} + API: {apiBaseUrl} Token Storage: {storageInfo.mode} + + {}}> + + + Server-Instanz festlegen + + Vor dem ersten Login bitte Server wählen. Standard verwenden oder eigene Instanz hinterlegen. + + + + + {serverError ? {serverError} : null} + + + {isServerSaving ? 'Speichern...' : 'Eigene Instanz speichern'} + + + + Standardserver verwenden + + + + ); } @@ -132,7 +253,7 @@ const styles = StyleSheet.create({ }, button: { marginTop: 6, - backgroundColor: '#16a34a', + backgroundColor: PRIMARY, borderRadius: 10, minHeight: 44, alignItems: 'center', @@ -157,8 +278,66 @@ const styles = StyleSheet.create({ borderTopColor: '#e5e7eb', gap: 4, }, + serverLink: { + marginTop: 2, + alignSelf: 'flex-start', + paddingVertical: 4, + }, + serverLinkText: { + color: PRIMARY, + fontSize: 13, + fontWeight: '600', + }, metaText: { fontSize: 12, color: '#6b7280', }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(17, 24, 39, 0.45)', + justifyContent: 'center', + padding: 20, + }, + modalCard: { + backgroundColor: '#ffffff', + borderRadius: 14, + padding: 16, + gap: 10, + }, + modalTitle: { + color: '#111827', + fontSize: 18, + fontWeight: '700', + }, + modalText: { + color: '#6b7280', + fontSize: 13, + lineHeight: 18, + }, + modalPrimaryButton: { + minHeight: 42, + borderRadius: 10, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + }, + modalPrimaryText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 14, + }, + modalSecondaryButton: { + minHeight: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: '#d1d5db', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ffffff', + }, + modalSecondaryText: { + color: '#374151', + fontWeight: '600', + fontSize: 14, + }, }); diff --git a/mobile/app/tenant-select.tsx b/mobile/app/tenant-select.tsx index 00dd269..13be4b2 100644 --- a/mobile/app/tenant-select.tsx +++ b/mobile/app/tenant-select.tsx @@ -1,15 +1,31 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Redirect, router } from 'expo-router'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; -import { useAuth } from '@/src/providers/auth-provider'; +import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; export default function TenantSelectScreen() { - const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant } = useAuth(); + const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant, user, logout } = useAuth(); + const storageInfo = useTokenStorageInfo(); const [switchingTenantId, setSwitchingTenantId] = useState(null); + const [search, setSearch] = useState(''); const [error, setError] = useState(null); + const filteredTenants = useMemo(() => { + const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return tenants; + + return tenants.filter((tenant) => { + const haystack = `${String(tenant.name || '').toLowerCase()} ${String(tenant.id || '').toLowerCase()} ${ + String(tenant.short || '').toLowerCase() + }`; + return terms.every((term) => haystack.includes(term)); + }); + }, [search, tenants]); + if (!token) { return ; } @@ -32,14 +48,34 @@ export default function TenantSelectScreen() { } } + async function onLogout() { + setSwitchingTenantId(null); + await logout(); + router.replace('/login'); + } + return ( - Tenant auswaehlen - Bitte waehle den Tenant fuer deine Session. + + Tenant auswählen + Wähle den Mandanten für diese Session. + User: {String(user?.email || user?.id || 'unbekannt')} + Storage: {storageInfo.mode} + {error ? {error} : null} - {tenants.map((tenant) => { + + + {filteredTenants.length === 0 ? Keine passenden Tenants gefunden. : null} + + {filteredTenants.map((tenant) => { const tenantId = Number(tenant.id); const isBusy = switchingTenantId === tenantId; @@ -49,14 +85,20 @@ export default function TenantSelectScreen() { style={[styles.tenantButton, isBusy ? styles.tenantButtonDisabled : null]} onPress={() => onSelectTenant(tenantId)} disabled={switchingTenantId !== null}> - - {tenant.name} + + + {tenant.name} + ID: {tenantId} - {isBusy ? : Auswaehlen} + {isBusy ? : Auswählen} ); })} + + + Anderen Nutzer anmelden + ); } @@ -65,21 +107,43 @@ const styles = StyleSheet.create({ container: { flexGrow: 1, padding: 16, - gap: 10, + gap: 12, backgroundColor: '#f9fafb', }, + headerCard: { + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + backgroundColor: '#ffffff', + padding: 14, + gap: 4, + }, title: { - fontSize: 24, + fontSize: 22, fontWeight: '700', color: '#111827', }, subtitle: { color: '#6b7280', - marginBottom: 8, + marginBottom: 2, + }, + meta: { + color: '#6b7280', + fontSize: 12, }, error: { color: '#dc2626', - marginBottom: 8, + fontSize: 13, + }, + searchInput: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + backgroundColor: '#ffffff', }, tenantButton: { borderRadius: 12, @@ -94,6 +158,11 @@ const styles = StyleSheet.create({ tenantButtonDisabled: { opacity: 0.6, }, + tenantInfo: { + flex: 1, + minWidth: 0, + paddingRight: 10, + }, tenantName: { fontSize: 16, fontWeight: '600', @@ -104,7 +173,27 @@ const styles = StyleSheet.create({ marginTop: 4, }, tenantAction: { - color: '#2563eb', + color: PRIMARY, + fontWeight: '600', + }, + empty: { + color: '#6b7280', + fontSize: 13, + textAlign: 'center', + paddingVertical: 8, + }, + logoutButton: { + marginTop: 4, + minHeight: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + }, + logoutText: { + color: '#374151', fontWeight: '600', }, }); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 10b3d4b..eea49a5 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -13,10 +13,13 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "expo": "~54.0.33", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", + "expo-document-picker": "^14.0.8", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.8", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", "expo-secure-store": "^15.0.8", @@ -28,11 +31,13 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-ble-plx": "^3.5.1", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", + "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1" }, "devDependencies": { @@ -6066,6 +6071,26 @@ "react-native": "*" } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -6080,6 +6105,15 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", @@ -6130,6 +6164,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -10274,6 +10329,19 @@ } } }, + "node_modules/react-native-ble-plx": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.1.tgz", + "integrity": "sha512-SxksmrUt9jG6DOarrrdkb5c/HBLSfZOKauo/9VQSSi3WJA4bmF78GkrtXrgSoGNk0m1ksacFTjB5DuL39xZq/g==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "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", @@ -10384,6 +10452,20 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-webview": { + "version": "13.16.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz", + "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index 3f6377e..d01858f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -3,12 +3,15 @@ "main": "expo-router/entry", "version": "1.0.0", "scripts": { - "start": "expo start", + "start": "expo start --dev-client --host lan", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios", + "ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device", "web": "expo start --web", - "lint": "expo lint" + "lint": "expo lint", + "build:ios:dev": "eas build --profile development --platform ios", + "build:ios:preview": "eas build --profile preview --platform ios" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -16,10 +19,13 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "expo": "~54.0.33", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", + "expo-document-picker": "^14.0.8", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.8", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", "expo-secure-store": "^15.0.8", @@ -31,11 +37,13 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-ble-plx": "^3.5.1", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", + "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1" }, "devDependencies": { diff --git a/mobile/src/config/env.ts b/mobile/src/config/env.ts index 0e818b9..2885df8 100644 --- a/mobile/src/config/env.ts +++ b/mobile/src/config/env.ts @@ -1,2 +1,2 @@ -export const API_BASE_URL = +export const DEFAULT_API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE?.replace(/\/$/, '') || 'http://localhost:3100'; diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index d600d04..17629da 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from '@/src/config/env'; +import { getApiBaseUrlSync } from '@/src/lib/server-config'; export type Tenant = { id: number; @@ -17,6 +17,9 @@ export type Task = { userId?: string | null; user_id?: string | null; profile?: string | null; + project?: number | { id?: number; name?: string } | null; + customer?: number | { id?: number; name?: string } | null; + plant?: number | { id?: number; name?: string } | null; archived?: boolean; [key: string]: unknown; }; @@ -42,6 +45,73 @@ export type StaffTimeSpan = { description: string; }; +export type Project = { + id: number; + name: string; + notes?: string | null; + projectNumber?: string | null; + archived?: boolean; + [key: string]: unknown; +}; + +export type ProjectFile = { + id: string; + name?: string | null; + path?: string | null; + project?: number | { id?: number; name?: string }; + customer?: number | { id?: number; name?: string }; + plant?: number | { id?: number; name?: string }; + createddocument?: number | { id?: number; documentNumber?: string }; + mimeType?: string | null; + url?: string; + archived?: boolean; + [key: string]: unknown; +}; + +export type Customer = { + id: number; + name: string; + customerNumber?: string | null; + notes?: string | null; + archived?: boolean; + [key: string]: unknown; +}; + +export type Plant = { + id: number; + name: string; + description?: string | null; + customer?: number | { id?: number; name?: string }; + archived?: boolean; + [key: string]: unknown; +}; + +export type CustomerInventoryItem = { + id: number; + name: string; + customer?: number | { id?: number; name?: string } | null; + customerInventoryId?: string | null; + serialNumber?: string | null; + description?: string | null; + manufacturer?: string | null; + manufacturerNumber?: string | null; + quantity?: number | null; + archived?: boolean; + [key: string]: unknown; +}; + +export type CreatedDocument = { + id: number; + documentNumber?: string | null; + title?: string | null; + type?: string | null; + state?: string | null; + documentDate?: string | null; + customer?: number | { id?: number; name?: string }; + archived?: boolean; + [key: string]: unknown; +}; + export type MeResponse = { user: { id: string; @@ -55,6 +125,52 @@ export type MeResponse = { permissions: string[]; }; +export type EncodedLabelRow = { + dataType: 'pixels' | 'void' | 'check'; + rowNumber: number; + repeat: number; + rowData?: Uint8Array | number[] | Record; + blackPixelsCount: number; +}; + +export type EncodedLabelImage = { + cols: number; + rows: number; + rowsData: EncodedLabelRow[]; +}; + +export type PrintLabelResponse = { + encoded: EncodedLabelImage; + base64?: string; +}; + +export type WikiTreeItem = { + id: string; + parentId?: string | null; + title: string; + isFolder?: boolean; + isVirtual?: boolean; + sortOrder?: number; + entityType?: string | null; + entityId?: number | null; + entityUuid?: string | null; + updatedAt?: string; + [key: string]: unknown; +}; + +export type WikiPage = { + id: string; + title: string; + content?: unknown; + parentId?: string | null; + isFolder?: boolean; + entityType?: string | null; + entityId?: number | null; + entityUuid?: string | null; + updatedAt?: string; + [key: string]: unknown; +}; + type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; token?: string | null; @@ -67,7 +183,7 @@ function buildUrl(path: string): string { } const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return `${API_BASE_URL}${normalizedPath}`; + return `${getApiBaseUrlSync()}${normalizedPath}`; } async function parseJson(response: Response): Promise { @@ -109,6 +225,23 @@ export async function checkBackendHealth(): Promise<{ status: string; [key: stri return apiRequest<{ status: string; [key: string]: unknown }>('/health'); } +export async function renderPrintLabel( + token: string, + context: Record, + width = 584, + height = 354 +): Promise { + return apiRequest('/api/print/label', { + method: 'POST', + token, + body: { + context, + width, + height, + }, + }); +} + export async function loginWithEmailPassword(email: string, password: string): Promise { const payload = await apiRequest<{ token?: string }>('/auth/login', { method: 'POST', @@ -154,6 +287,9 @@ export async function createTask( description?: string | null; categorie?: TaskStatus; userId?: string | null; + project?: number | null; + customer?: number | null; + plant?: number | null; } ): Promise { return apiRequest('/api/resource/tasks', { @@ -258,3 +394,523 @@ export async function rejectStaffTime( body: { eventIds, employeeUserId, reason }, }); } + +export async function fetchProjects(token: string, includeArchived = false): Promise { + const projects = await apiRequest('/api/resource/projects', { token }); + if (includeArchived) return projects || []; + return (projects || []).filter((project) => !project.archived); +} + +export async function createProject( + token: string, + payload: { + name: string; + projectNumber?: string | null; + customer?: number | null; + plant?: number | null; + notes?: string | null; + } +): Promise { + return apiRequest('/api/resource/projects', { + method: 'POST', + token, + body: payload, + }); +} + +export async function fetchCustomers(token: string, includeArchived = false): Promise { + const customers = await apiRequest('/api/resource/customers', { token }); + if (includeArchived) return customers || []; + return (customers || []).filter((customer) => !customer.archived); +} + +export async function createCustomer( + token: string, + payload: { + name: string; + customerNumber?: string | null; + notes?: string | null; + } +): Promise { + return apiRequest('/api/resource/customers', { + method: 'POST', + token, + body: payload, + }); +} + +export async function fetchCustomerById(token: string, customerId: number): Promise { + return apiRequest(`/api/resource/customers/${customerId}`, { token }); +} + +function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null { + const rawCustomer = item.customer; + if (!rawCustomer) return null; + if (typeof rawCustomer === 'object') { + return rawCustomer.id ? Number(rawCustomer.id) : null; + } + return Number(rawCustomer); +} + +export async function fetchCustomerInventoryItems( + token: string, + customerId: number, + includeArchived = false +): Promise { + const rows = await apiRequest('/api/resource/customerinventoryitems', { token }); + + return (rows || []).filter((item) => { + if (!includeArchived && item.archived) return false; + return resolveCustomerIdFromCustomerInventoryItem(item) === Number(customerId); + }); +} + +export async function fetchAllCustomerInventoryItems( + token: string, + includeArchived = false +): Promise { + const rows = await apiRequest('/api/resource/customerinventoryitems', { token }); + if (includeArchived) return rows || []; + return (rows || []).filter((item) => !item.archived); +} + +export async function createCustomerInventoryItem( + token: string, + payload: { + customer: number; + name: string; + customerInventoryId?: string | null; + serialNumber?: string | null; + description?: string | null; + quantity?: number | null; + } +): Promise { + const autoInventoryId = `MOB-${Date.now()}`; + + return apiRequest('/api/resource/customerinventoryitems', { + method: 'POST', + token, + body: { + customer: payload.customer, + name: payload.name, + customerInventoryId: payload.customerInventoryId?.trim() || autoInventoryId, + serialNumber: payload.serialNumber?.trim() || null, + description: payload.description?.trim() || null, + quantity: Number.isFinite(Number(payload.quantity)) ? Number(payload.quantity) : 1, + }, + }); +} + +export async function fetchPlants(token: string, includeArchived = false): Promise { + const plants = await apiRequest('/api/resource/plants', { token }); + if (includeArchived) return plants || []; + return (plants || []).filter((plant) => !plant.archived); +} + +export async function createPlant( + token: string, + payload: { + name: string; + description?: string | null; + customer?: number | null; + } +): Promise { + return apiRequest('/api/resource/plants', { + method: 'POST', + token, + body: payload, + }); +} + +export async function fetchPlantById(token: string, plantId: number): Promise { + return apiRequest(`/api/resource/plants/${plantId}`, { token }); +} + +function toQueryString(params: Record): string { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + searchParams.append(key, String(value)); + }); + const query = searchParams.toString(); + return query ? `?${query}` : ''; +} + +export async function fetchWikiTree( + token: string, + filters: { + entityType?: string; + entityId?: number | null; + entityUuid?: string | null; + } = {} +): Promise { + const query = toQueryString({ + entityType: filters.entityType, + entityId: filters.entityId, + entityUuid: filters.entityUuid, + }); + return apiRequest(`/api/wiki/tree${query}`, { token }); +} + +export async function fetchWikiPageById(token: string, pageId: string): Promise { + return apiRequest(`/api/wiki/${encodeURIComponent(pageId)}`, { token }); +} + +export async function createWikiPage( + token: string, + payload: { + title: string; + parentId?: string | null; + isFolder?: boolean; + entityType?: string; + entityId?: number | null; + entityUuid?: string | null; + } +): Promise { + return apiRequest('/api/wiki', { + method: 'POST', + token, + body: payload, + }); +} + +export async function updateWikiPage( + token: string, + pageId: string, + payload: { + title?: string; + content?: unknown; + parentId?: string | null; + sortOrder?: number; + isFolder?: boolean; + } +): Promise { + return apiRequest(`/api/wiki/${encodeURIComponent(pageId)}`, { + method: 'PATCH', + token, + body: payload, + }); +} + +export async function deleteWikiPage(token: string, pageId: string): Promise<{ success: boolean; deletedId?: string }> { + return apiRequest<{ success: boolean; deletedId?: string }>(`/api/wiki/${encodeURIComponent(pageId)}`, { + method: 'DELETE', + token, + }); +} + +export async function fetchProjectById(token: string, projectId: number): Promise { + return apiRequest(`/api/resource/projects/${projectId}`, { token }); +} + +function resolveProjectIdFromTask(task: Task): number | null { + const rawProject = task.project; + if (!rawProject) return null; + if (typeof rawProject === 'object') { + return rawProject.id ? Number(rawProject.id) : null; + } + return Number(rawProject); +} + +export async function fetchProjectTasks(token: string, projectId: number): Promise { + const tasks = await fetchTasks(token); + return (tasks || []).filter((task) => resolveProjectIdFromTask(task) === Number(projectId)); +} + +export async function createProjectTask( + token: string, + payload: { + projectId: number; + name: string; + description?: string | null; + userId?: string | null; + categorie?: TaskStatus; + } +): Promise { + return createTask(token, { + name: payload.name, + description: payload.description || null, + userId: payload.userId || null, + categorie: payload.categorie || 'Offen', + project: payload.projectId, + }); +} + +function resolveProjectIdFromFile(file: ProjectFile): number | null { + const rawProject = file.project; + if (!rawProject) return null; + if (typeof rawProject === 'object') { + return rawProject.id ? Number(rawProject.id) : null; + } + return Number(rawProject); +} + +function resolveCustomerIdFromFile(file: ProjectFile): number | null { + const rawCustomer = file.customer; + if (!rawCustomer) return null; + if (typeof rawCustomer === 'object') { + return rawCustomer.id ? Number(rawCustomer.id) : null; + } + return Number(rawCustomer); +} + +function resolvePlantIdFromFile(file: ProjectFile): number | null { + const rawPlant = file.plant; + if (!rawPlant) return null; + if (typeof rawPlant === 'object') { + return rawPlant.id ? Number(rawPlant.id) : null; + } + return Number(rawPlant); +} + +function resolveCreatedDocumentIdFromFile(file: ProjectFile): number | null { + const rawCreatedDocument = file.createddocument; + if (!rawCreatedDocument) return null; + if (typeof rawCreatedDocument === 'object') { + return rawCreatedDocument.id ? Number(rawCreatedDocument.id) : null; + } + return Number(rawCreatedDocument); +} + +function resolveCustomerIdFromCreatedDocument(doc: CreatedDocument): number | null { + const rawCustomer = doc.customer; + if (!rawCustomer) return null; + if (typeof rawCustomer === 'object') { + return rawCustomer.id ? Number(rawCustomer.id) : null; + } + return Number(rawCustomer); +} + +export async function fetchProjectFiles(token: string, projectId: number): Promise { + const files = await apiRequest('/api/resource/files', { token }); + const projectFiles = (files || []).filter((file) => { + if (file.archived) return false; + return resolveProjectIdFromFile(file) === Number(projectId); + }); + + if (projectFiles.length === 0) return []; + + const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', { + method: 'POST', + token, + body: { ids: projectFiles.map((file) => file.id) }, + }); + + return presigned.files || []; +} + +export async function fetchCustomerFiles(token: string, customerId: number): Promise { + const files = await apiRequest('/api/resource/files', { token }); + const customerFiles = (files || []).filter((file) => { + if (file.archived) return false; + return resolveCustomerIdFromFile(file) === Number(customerId); + }); + + if (customerFiles.length === 0) return []; + + const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', { + method: 'POST', + token, + body: { ids: customerFiles.map((file) => file.id) }, + }); + + return presigned.files || []; +} + +export async function fetchPlantFiles(token: string, plantId: number): Promise { + const files = await apiRequest('/api/resource/files', { token }); + const plantFiles = (files || []).filter((file) => { + if (file.archived) return false; + return resolvePlantIdFromFile(file) === Number(plantId); + }); + + if (plantFiles.length === 0) return []; + + const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', { + method: 'POST', + token, + body: { ids: plantFiles.map((file) => file.id) }, + }); + + return presigned.files || []; +} + +export async function fetchCustomerCreatedDocuments( + token: string, + customerId: number +): Promise { + const docs = await apiRequest('/api/resource/createddocuments', { token }); + + return (docs || []) + .filter((doc) => !doc.archived && resolveCustomerIdFromCreatedDocument(doc) === Number(customerId)) + .sort((a, b) => { + const dateA = new Date(String(a.documentDate || '')).getTime(); + const dateB = new Date(String(b.documentDate || '')).getTime(); + return dateB - dateA; + }); +} + +export async function fetchCreatedDocumentFiles( + token: string, + createdDocumentId: number +): Promise { + const files = await apiRequest('/api/resource/files', { token }); + const createdDocumentFiles = (files || []).filter((file) => { + if (file.archived) return false; + return resolveCreatedDocumentIdFromFile(file) === Number(createdDocumentId); + }); + + if (createdDocumentFiles.length === 0) return []; + + const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', { + method: 'POST', + token, + body: { ids: createdDocumentFiles.map((file) => file.id) }, + }); + + return presigned.files || []; +} + +export async function uploadProjectFile( + token: string, + payload: { + projectId: number; + uri: string; + filename: string; + mimeType?: string; + } +): Promise { + const formData = new FormData(); + formData.append( + 'file', + { + uri: payload.uri, + name: payload.filename, + type: payload.mimeType || 'application/octet-stream', + } as any + ); + formData.append( + 'meta', + JSON.stringify({ + project: payload.projectId, + name: payload.filename, + mimeType: payload.mimeType || 'application/octet-stream', + }) + ); + + const response = await fetch(buildUrl('/api/files/upload'), { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'application/json', + }, + body: formData, + }); + + const parsed = await parseJson(response); + if (!response.ok) { + const message = + (parsed as { message?: string; error?: string } | null)?.message || + (parsed as { message?: string; error?: string } | null)?.error || + 'Upload fehlgeschlagen.'; + throw new Error(message); + } + + return parsed as ProjectFile; +} + +export async function uploadCustomerFile( + token: string, + payload: { + customerId: number; + uri: string; + filename: string; + mimeType?: string; + } +): Promise { + const formData = new FormData(); + formData.append( + 'file', + { + uri: payload.uri, + name: payload.filename, + type: payload.mimeType || 'application/octet-stream', + } as any + ); + formData.append( + 'meta', + JSON.stringify({ + customer: payload.customerId, + name: payload.filename, + mimeType: payload.mimeType || 'application/octet-stream', + }) + ); + + const response = await fetch(buildUrl('/api/files/upload'), { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'application/json', + }, + body: formData, + }); + + const parsed = await parseJson(response); + if (!response.ok) { + const message = + (parsed as { message?: string; error?: string } | null)?.message || + (parsed as { message?: string; error?: string } | null)?.error || + 'Upload fehlgeschlagen.'; + throw new Error(message); + } + + return parsed as ProjectFile; +} + +export async function uploadPlantFile( + token: string, + payload: { + plantId: number; + uri: string; + filename: string; + mimeType?: string; + } +): Promise { + const formData = new FormData(); + formData.append( + 'file', + { + uri: payload.uri, + name: payload.filename, + type: payload.mimeType || 'application/octet-stream', + } as any + ); + formData.append( + 'meta', + JSON.stringify({ + plant: payload.plantId, + name: payload.filename, + mimeType: payload.mimeType || 'application/octet-stream', + }) + ); + + const response = await fetch(buildUrl('/api/files/upload'), { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'application/json', + }, + body: formData, + }); + + const parsed = await parseJson(response); + if (!response.ok) { + const message = + (parsed as { message?: string; error?: string } | null)?.message || + (parsed as { message?: string; error?: string } | null)?.error || + 'Upload fehlgeschlagen.'; + throw new Error(message); + } + + return parsed as ProjectFile; +} diff --git a/mobile/src/providers/auth-provider.tsx b/mobile/src/providers/auth-provider.tsx index 53e8be2..24ff80b 100644 --- a/mobile/src/providers/auth-provider.tsx +++ b/mobile/src/providers/auth-provider.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api'; +import { hydrateApiBaseUrl } from '@/src/lib/server-config'; import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage'; export type AuthUser = MeResponse['user']; @@ -82,6 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { async function bootstrap() { + await hydrateApiBaseUrl(); const storedToken = await getStoredToken(); if (!storedToken) {