feat: Implement mobile application and lead processing utilities.
This commit is contained in:
@@ -1,152 +1,379 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, StyleSheet, Platform,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useTerminDetail, useToggleAnmeldung } from '@/hooks/useTermine'
|
||||
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import * as Calendar from 'expo-calendar'
|
||||
|
||||
export default function TerminDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
|
||||
const toggleMutation = trpc.termine.toggleAnmeldung.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate queries
|
||||
},
|
||||
})
|
||||
|
||||
async function addToCalendar() {
|
||||
if (!termin) return
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync()
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Keine Berechtigung', 'Bitte erlauben Sie den Kalender-Zugriff in den Einstellungen.')
|
||||
return
|
||||
}
|
||||
|
||||
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
|
||||
const defaultCal = calendars.find((c) => c.isPrimary) ?? calendars[0]
|
||||
|
||||
if (!defaultCal) {
|
||||
Alert.alert('Kein Kalender', 'Es wurde kein Kalender gefunden.')
|
||||
return
|
||||
}
|
||||
|
||||
const startDate = new Date(termin.datum)
|
||||
if (termin.uhrzeit) {
|
||||
const [h, m] = termin.uhrzeit.split(':').map(Number)
|
||||
startDate.setHours(h, m)
|
||||
}
|
||||
|
||||
await Calendar.createEventAsync(defaultCal.id, {
|
||||
title: termin.titel,
|
||||
startDate,
|
||||
endDate: startDate,
|
||||
location: termin.adresse ?? termin.ort ?? undefined,
|
||||
notes: termin.beschreibung ?? undefined,
|
||||
})
|
||||
|
||||
Alert.alert('Termin gespeichert', 'Der Termin wurde in Ihren Kalender eingetragen.')
|
||||
}
|
||||
const { data: termin, isLoading } = useTerminDetail(id)
|
||||
const { mutate, isPending } = useToggleAnmeldung()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#E63946" />
|
||||
</SafeAreaView>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!termin) return null
|
||||
|
||||
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
|
||||
const datum = new Date(termin.datum)
|
||||
const isPast = datum < new Date()
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
|
||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
||||
<Text className="text-brand-500 text-base">← Zurück</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<View className="bg-white px-4 py-6 border-b border-gray-100">
|
||||
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||
<Text className="text-2xl font-bold text-gray-900 mt-3">{termin.titel}</Text>
|
||||
<Text className="text-gray-500 mt-2 capitalize">{datumFormatted}</Text>
|
||||
{termin.uhrzeit && (
|
||||
<Text className="text-gray-500">
|
||||
{termin.uhrzeit}{termin.endeUhrzeit ? ` – ${termin.endeUhrzeit}` : ''} Uhr
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
||||
{termin.ort && (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
termin.adresse &&
|
||||
Linking.openURL(
|
||||
`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`
|
||||
)
|
||||
}
|
||||
className="flex-row items-center px-4 py-3 border-b border-gray-50"
|
||||
>
|
||||
<Text className="text-2xl mr-3">📍</Text>
|
||||
<View>
|
||||
<Text className="font-medium text-gray-900">{termin.ort}</Text>
|
||||
{termin.adresse && (
|
||||
<Text className="text-sm text-brand-500 mt-0.5">{termin.adresse}</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Text className="text-2xl mr-3">👥</Text>
|
||||
<Text className="text-gray-700">
|
||||
{termin.teilnehmerAnzahl} Anmeldungen
|
||||
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer} Plätze` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{termin.beschreibung && (
|
||||
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
|
||||
<Text className="font-semibold text-gray-900 mb-2">Beschreibung</Text>
|
||||
<Text className="text-gray-600 leading-6">{termin.beschreibung}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View className="mx-4 mt-4 gap-3">
|
||||
<AnmeldeButton
|
||||
terminId={id}
|
||||
isAngemeldet={termin.isAngemeldet}
|
||||
onToggle={() => toggleMutation.mutate({ terminId: id })}
|
||||
isLoading={toggleMutation.isPending}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={addToCalendar}
|
||||
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
|
||||
>
|
||||
<Text className="text-gray-900 text-xl">📅</Text>
|
||||
<Text className="text-gray-900 font-semibold">Zum Kalender hinzufügen</Text>
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerSpacer} />
|
||||
<TouchableOpacity style={styles.shareButton}>
|
||||
<Ionicons name="share-outline" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="h-8" />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{/* Date Badge */}
|
||||
<View style={styles.dateline}>
|
||||
<View style={styles.calendarIcon}>
|
||||
<Ionicons name="calendar" size={18} color="#003B7E" />
|
||||
</View>
|
||||
<Text style={styles.dateText}>
|
||||
{format(datum, 'EEEE, d. MMMM yyyy', { locale: de })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{termin.titel}</Text>
|
||||
|
||||
<View style={styles.badgesRow}>
|
||||
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||
{termin.isAngemeldet && (
|
||||
<View style={styles.registeredBadge}>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||
<Text style={styles.registeredText}>Angemeldet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Card */}
|
||||
<View style={styles.card}>
|
||||
{/* Time */}
|
||||
{termin.uhrzeit && (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="time-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Uhrzeit</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{termin.uhrzeit}
|
||||
{termin.endeUhrzeit ? ` – ${termin.endeUhrzeit}` : ''} Uhr
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{termin.uhrzeit && termin.ort && <View style={styles.divider} />}
|
||||
|
||||
{/* Location */}
|
||||
{termin.ort && (
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() =>
|
||||
termin.adresse &&
|
||||
Linking.openURL(`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`)
|
||||
}
|
||||
activeOpacity={termin.adresse ? 0.7 : 1}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="location-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Ort</Text>
|
||||
<Text style={styles.infoValue}>{termin.ort}</Text>
|
||||
{termin.adresse && (
|
||||
<Text style={styles.infoSub}>{termin.adresse}</Text>
|
||||
)}
|
||||
</View>
|
||||
{termin.adresse && (
|
||||
<Ionicons name="chevron-forward" size={20} color="#CBD5E1" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{(termin.uhrzeit || termin.ort) && (termin.teilnehmerAnzahl !== undefined) && <View style={styles.divider} />}
|
||||
|
||||
{/* Participants */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="people-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Teilnehmer</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{termin.teilnehmerAnzahl} Anmeldungen
|
||||
</Text>
|
||||
{termin.maxTeilnehmer && (
|
||||
<Text style={styles.infoSub}>{termin.maxTeilnehmer} Plätze verfügbar</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{termin.beschreibung && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Beschreibung</Text>
|
||||
<Text style={styles.description}>{termin.beschreibung}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action Bar – only for upcoming events */}
|
||||
{isPast ? (
|
||||
<View style={styles.pastBar}>
|
||||
<Ionicons name="time-outline" size={16} color="#94A3B8" />
|
||||
<Text style={styles.pastBarText}>Vergangener Termin – keine Anmeldung möglich</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.actionBar}>
|
||||
<View style={styles.actionButtonContainer}>
|
||||
<AnmeldeButton
|
||||
terminId={id}
|
||||
isAngemeldet={termin.isAngemeldet}
|
||||
onToggle={() => mutate({ terminId: id })}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.calendarButton}
|
||||
onPress={() => Alert.alert('Kalender', 'Funktion folgt in Kürze')}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</SafeAreaView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
shareButton: {
|
||||
padding: 8,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 24,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
dateline: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
calendarIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#EFF6FF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#003B7E',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
lineHeight: 34,
|
||||
marginBottom: 16,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
badgesRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 32,
|
||||
},
|
||||
registeredBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: '#ECFDF5',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#A7F3D0',
|
||||
},
|
||||
registeredText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#047857',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
shadowColor: '#64748B',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
marginBottom: 32,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16,
|
||||
},
|
||||
iconBox: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#F1F5F9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoContent: {
|
||||
flex: 1,
|
||||
paddingTop: 2,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#94A3B8',
|
||||
marginBottom: 2,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
lineHeight: 22,
|
||||
},
|
||||
infoSub: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
marginTop: 2,
|
||||
lineHeight: 20,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#F1F5F9',
|
||||
marginVertical: 16,
|
||||
marginLeft: 56,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
lineHeight: 26,
|
||||
color: '#334155',
|
||||
},
|
||||
actionBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.02,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
pastBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#F8FAFC',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
paddingBottom: Platform.OS === 'ios' ? 28 : 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E2E8F0',
|
||||
},
|
||||
pastBarText: {
|
||||
fontSize: 13,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionButtonContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
calendarButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F1F5F9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user