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:
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal file
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal file
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user