Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation

- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien)
- ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten
- DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views
- USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister)
- PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen
- BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution
- ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs
- COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung
- API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions
- ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall
- GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung
- AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz
- DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response
- FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Knuth
2026-02-18 19:03:37 +01:00
parent fc68285cf1
commit fca42db4d2
116 changed files with 9329 additions and 6479 deletions

View File

@@ -0,0 +1,78 @@
import { Tabs } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
import { Redirect } from 'expo-router'
import { Platform } from 'react-native'
function TabIcon({ emoji }: { emoji: string }) {
return null // Replaced by tabBarIcon in options
}
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
if (!session) {
return <Redirect href="/(auth)/login" />
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#E63946',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: {
borderTopColor: '#e5e7eb',
backgroundColor: 'white',
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
height: Platform.OS === 'ios' ? 82 : 60,
},
headerStyle: { backgroundColor: 'white' },
headerTitleStyle: { fontWeight: '700', color: '#111827' },
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="news"
options={{
title: 'News',
tabBarIcon: ({ color }) => (
/* Replace with actual icons after @expo/vector-icons setup */
<TabIcon emoji="📰" />
),
headerShown: false,
}}
/>
<Tabs.Screen
name="members"
options={{
title: 'Mitglieder',
tabBarIcon: () => <TabIcon emoji="👥" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="termine"
options={{
title: 'Termine',
tabBarIcon: () => <TabIcon emoji="📅" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="stellen"
options={{
title: 'Stellen',
tabBarIcon: () => <TabIcon emoji="🎓" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="profil"
options={{
title: 'Profil',
tabBarIcon: () => <TabIcon emoji="👤" />,
headerShown: false,
}}
/>
</Tabs>
)
}

View File

@@ -0,0 +1,107 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { Avatar } from '@/components/ui/Avatar'
export default function MemberDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!member) return null
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<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>
{/* Profile Header */}
<View className="bg-white px-6 py-8 items-center border-b border-gray-100">
<Avatar
name={member.name}
imageUrl={member.avatarUrl ?? undefined}
size={80}
/>
<Text className="text-2xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
{member.istAusbildungsbetrieb && (
<View className="mt-2 bg-green-100 px-3 py-1 rounded-full">
<Text className="text-green-700 text-xs font-medium">
🎓 Ausbildungsbetrieb
</Text>
</View>
)}
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && (
<InfoRow label="Mitglied seit" value={String(member.seit)} />
)}
</View>
{/* Contact Buttons */}
<View className="mx-4 mt-4 gap-3">
{member.telefon && (
<TouchableOpacity
onPress={() => Linking.openURL(`tel:${member.telefon}`)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl">📞</Text>
<Text className="text-white font-semibold text-base">
Anrufen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() =>
Linking.openURL(
`mailto:${member.email}?subject=InnungsApp%20Anfrage`
)
}
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 text-base">
E-Mail senden
</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,101 @@
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useMembersFilterStore } from '@/store/members.store'
import { MemberCard } from '@/components/members/MemberCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function MembersScreen() {
const router = useRouter()
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
const setSearch = useMembersFilterStore((s) => s.setSearch)
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-2 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Mitglieder</Text>
{/* Search */}
<View className="flex-row items-center bg-gray-100 rounded-xl px-3 py-2 mb-2">
<Text className="text-gray-400 mr-2">🔍</Text>
<TextInput
className="flex-1 text-sm text-gray-900"
placeholder="Name, Betrieb, Ort, Sparte..."
placeholderTextColor="#9ca3af"
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
{/* Filter: Ausbildungsbetriebe */}
<TouchableOpacity
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
className="flex-row items-center gap-2 py-1"
>
<View
className={`w-5 h-5 rounded border-2 items-center justify-center ${
nurAusbildungsbetriebe
? 'bg-brand-500 border-brand-500'
: 'border-gray-300 bg-white'
}`}
>
{nurAusbildungsbetriebe && (
<Text className="text-white text-xs"></Text>
)}
</View>
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
</TouchableOpacity>
</View>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<MemberCard
member={item}
onPress={() => router.push(`/(app)/members/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="👥"
title="Keine Mitglieder"
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,87 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect } from 'react'
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
import { AttachmentRow } from '@/components/news/AttachmentRow'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
export default function NewsDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
useEffect(() => {
if (news) {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
}, [news?.id])
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!news) return null
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
<Text className="font-semibold text-gray-900 flex-1" numberOfLines={1}>
{news.title}
</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<Text className="text-2xl font-bold text-gray-900 mt-3 mb-2">
{news.title}
</Text>
<Text className="text-sm text-gray-500 mb-6">
{news.author?.name ?? 'InnungsApp'} ·{' '}
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })
: ''}
</Text>
{/* Simple Markdown renderer — plain text for MVP */}
<Text className="text-base text-gray-700 leading-7">
{news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')}
</Text>
{/* Attachments */}
{news.attachments.length > 0 && (
<View className="mt-8 border-t border-gray-100 pt-4">
<Text className="font-semibold text-gray-900 mb-3">Anhänge</Text>
{news.attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
</View>
)}
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,100 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
ScrollView,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { useRouter } from 'expo-router'
import { NewsCard } from '@/components/news/NewsCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
const FILTER_OPTIONS = [
{ value: undefined, label: 'Alle' },
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
]
export default function NewsScreen() {
const router = useRouter()
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({
kategorie: kategorie as never,
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">News</Text>
</View>
{/* Kategorie Filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="bg-white border-b border-gray-100"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8 }}
>
{FILTER_OPTIONS.map((opt) => (
<TouchableOpacity
key={String(opt.value)}
onPress={() => setKategorie(opt.value)}
className={`px-4 py-1.5 rounded-full border ${
kategorie === opt.value
? 'bg-brand-500 border-brand-500'
: 'bg-white border-gray-200'
}`}
>
<Text
className={`text-sm font-medium ${
kategorie === opt.value ? 'text-white' : 'text-gray-600'
}`}
>
{opt.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<NewsCard
news={item}
onPress={() => router.push(`/(app)/news/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📰"
title="Keine News"
subtitle="Noch keine Beiträge für diese Kategorie"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,97 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { trpc } from '@/lib/trpc'
import { useAuth } from '@/hooks/useAuth'
import { Avatar } from '@/components/ui/Avatar'
export default function ProfilScreen() {
const { signOut } = useAuth()
const { data: member, isLoading } = trpc.members.me.useQuery()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">Mein Profil</Text>
</View>
<ScrollView>
{isLoading ? (
<View className="py-16 items-center">
<ActivityIndicator color="#E63946" />
</View>
) : member ? (
<>
{/* Profile */}
<View className="bg-white px-4 py-8 items-center border-b border-gray-100">
<Avatar name={member.name} size={72} />
<Text className="text-xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
<Text className="text-gray-400 text-sm mt-0.5">{member.org.name}</Text>
</View>
{/* Member Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="E-Mail" value={member.email} />
{member.telefon && <InfoRow label="Telefon" value={member.telefon} />}
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && <InfoRow label="Mitglied seit" value={String(member.seit)} />}
</View>
<View className="mx-4 mt-2">
<Text className="text-xs text-gray-400 px-1">
Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor.
</Text>
</View>
</>
) : null}
{/* Links */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/datenschutz')}
className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50"
>
<Text className="text-gray-700">Datenschutzerklärung</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/impressum')}
className="flex-row items-center justify-between px-4 py-3.5"
>
<Text className="text-gray-700">Impressum</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
</View>
{/* Logout */}
<View className="mx-4 mt-4">
<TouchableOpacity
onPress={signOut}
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
>
<Text className="text-red-600 font-semibold">Abmelden</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-28">{label}</Text>
<Text className="text-sm text-gray-900 flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,94 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
export default function StelleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!stelle) return null
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
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>
{/* Header */}
<View className="bg-white px-4 py-6 border-b border-gray-100">
<View className="bg-brand-50 rounded-xl w-16 h-16 items-center justify-center mb-4">
<Text className="text-3xl">🎓</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">{stelle.member.betrieb}</Text>
<Text className="text-gray-500 mt-1">{stelle.member.ort}</Text>
<Text className="text-gray-500">{stelle.org.name}</Text>
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<DetailRow label="Sparte" value={stelle.sparte} />
<DetailRow label="Anzahl Stellen" value={String(stelle.stellenAnz)} />
{stelle.lehrjahr && <DetailRow label="Lehrjahr" value={stelle.lehrjahr} />}
{stelle.verguetung && <DetailRow label="Vergütung" value={stelle.verguetung} />}
</View>
{stelle.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">Über die Stelle</Text>
<Text className="text-gray-600 leading-6">{stelle.beschreibung}</Text>
</View>
)}
{/* CTA */}
<View className="mx-4 mt-6">
<TouchableOpacity
onPress={() => Linking.openURL(bewerbungsUrl)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl"></Text>
<Text className="text-white font-semibold text-base">Jetzt bewerben</Text>
</TouchableOpacity>
{stelle.kontaktName && (
<Text className="text-center text-sm text-gray-400 mt-3">
Ansprechperson: {stelle.kontaktName}
</Text>
)}
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,74 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { useAuth } from '@/hooks/useAuth'
export default function StellenScreen() {
const router = useRouter()
const { isAuthenticated } = useAuth()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-3 border-b border-gray-100">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xl font-bold text-gray-900">Lehrlingsbörse</Text>
<Text className="text-sm text-gray-500 mt-0.5">
{data?.length ?? 0} Angebote
</Text>
</View>
{isAuthenticated && (
<TouchableOpacity
onPress={() => router.push('/(app)/stellen/neu')}
className="bg-brand-500 px-4 py-2 rounded-xl"
>
<Text className="text-white text-sm font-medium">+ Stelle anbieten</Text>
</TouchableOpacity>
)}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,152 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
Alert,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
import { Badge } from '@/components/ui/Badge'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
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.')
}
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!termin) return null
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
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>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,82 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { TerminCard } from '@/components/termine/TerminCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function TermineScreen() {
const router = useRouter()
const [tab, setTab] = useState<'kommend' | 'vergangen'>('kommend')
const { data, isLoading, refetch, isRefetching } = trpc.termine.list.useQuery({
upcoming: tab === 'kommend',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Termine</Text>
{/* Tabs */}
<View className="flex-row gap-1 bg-gray-100 p-1 rounded-xl">
{(['kommend', 'vergangen'] as const).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setTab(t)}
className={`flex-1 py-2 rounded-lg items-center ${
tab === t ? 'bg-white shadow-sm' : ''
}`}
>
<Text
className={`text-sm font-medium ${
tab === t ? 'text-gray-900' : 'text-gray-500'
}`}
>
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<TerminCard
termin={item}
onPress={() => router.push(`/(app)/termine/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📅"
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
subtitle="Es sind aktuell keine Termine eingetragen"
/>
}
/>
)}
</SafeAreaView>
)
}