feat: Implement mobile application and lead processing utilities.

This commit is contained in:
2026-02-19 14:21:51 +01:00
parent fca42db4d2
commit c53a71a5f9
120 changed files with 24080 additions and 851 deletions

View File

@@ -1,94 +1,335 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, 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 { useStelleDetail } from '@/hooks/useStellen'
import { Button } from '@/components/ui/Button'
const SPARTE_COLOR: Record<string, string> = {
elektro: '#1D4ED8', sanitär: '#0E7490', it: '#7C3AED',
info: '#7C3AED', heizung: '#D97706', maler: '#059669',
}
function getSparteColor(sparte: string): string {
const lower = sparte.toLowerCase()
for (const [k, v] of Object.entries(SPARTE_COLOR)) {
if (lower.includes(k)) return v
}
return '#003B7E'
}
export default function StelleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
const { data: stelle, isLoading } = useStelleDetail(id)
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 (!stelle) return null
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
const color = getSparteColor(stelle.sparte)
const initial = stelle.sparte.charAt(0).toUpperCase()
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(`Bewerbung als Auszubildender bei ${stelle.member.betrieb}`)}`
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>
<>
<Stack.Screen options={{ headerShown: false }} />
<SafeAreaView style={styles.container} edges={['top']}>
{/* 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>
<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>
{stelle.kontaktName && (
<Text className="text-center text-sm text-gray-400 mt-3">
Ansprechperson: {stelle.kontaktName}
</Text>
)}
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Header Card */}
<View style={styles.heroCard}>
<View style={[styles.logoBox, { backgroundColor: color + '15' }]}>
<Text style={[styles.logoText, { color }]}>{initial}</Text>
</View>
<Text style={styles.jobTitle}>Auszubildender {stelle.sparte}</Text>
<Text style={styles.companyName}>{stelle.member.betrieb}</Text>
<View style={styles.locationBadge}>
<Ionicons name="location-sharp" size={14} color="#64748B" />
<Text style={styles.locationText}>
{stelle.member.ort} · {stelle.org.name}
</Text>
</View>
</View>
{/* Key Facts */}
<Text style={styles.sectionHeader}>Eckdaten</Text>
<View style={styles.factsContainer}>
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="people-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Anzahl Stellen</Text>
<Text style={styles.factValue}>{stelle.stellenAnz}</Text>
</View>
</View>
{stelle.lehrjahr && (
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="school-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Lehrjahr</Text>
<Text style={styles.factValue}>{stelle.lehrjahr}</Text>
</View>
</View>
)}
{stelle.verguetung && (
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="cash-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Vergütung</Text>
<Text style={styles.factValue}>{stelle.verguetung}</Text>
</View>
</View>
)}
</View>
{/* Description */}
{stelle.beschreibung && (
<View style={styles.section}>
<Text style={styles.sectionHeader}>Beschreibung</Text>
<Text style={styles.description}>{stelle.beschreibung}</Text>
</View>
)}
{/* Contact */}
{stelle.kontaktName && (
<View style={styles.contactBox}>
<Text style={styles.contactTitle}>Ansprechpartner</Text>
<View style={styles.contactRow}>
<View style={styles.avatarPlaceholder}>
<Text style={styles.avatarInitials}>{stelle.kontaktName.charAt(0)}</Text>
</View>
<View>
<Text style={styles.contactName}>{stelle.kontaktName}</Text>
<Text style={styles.contactRole}>Recruiting</Text>
</View>
</View>
</View>
)}
</ScrollView>
{/* Footer */}
<View style={styles.footer}>
<Button label="Jetzt bewerben" onPress={() => Linking.openURL(bewerbungsUrl)} />
</View>
</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>
)
}
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: 40,
},
heroCard: {
alignItems: 'center',
marginBottom: 32,
},
logoBox: {
width: 80,
height: 80,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 32,
fontWeight: '800',
},
jobTitle: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
textAlign: 'center',
marginBottom: 8,
letterSpacing: -0.5,
},
companyName: {
fontSize: 16,
fontWeight: '600',
color: '#64748B',
marginBottom: 16,
},
locationBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F1F5F9',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 99,
gap: 6,
},
locationText: {
fontSize: 13,
color: '#475569',
fontWeight: '500',
},
sectionHeader: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
},
factsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 32,
},
factItem: {
flex: 1,
minWidth: '45%',
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
shadowColor: '#64748B',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 1,
},
factIconBox: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#EFF6FF',
justifyContent: 'center',
alignItems: 'center',
},
factLabel: {
fontSize: 11,
color: '#94A3B8',
fontWeight: '600',
textTransform: 'uppercase',
marginBottom: 2,
},
factValue: {
fontSize: 14,
fontWeight: '700',
color: '#0F172A',
},
section: {
marginBottom: 32,
},
description: {
fontSize: 16,
lineHeight: 26,
color: '#334155',
},
contactBox: {
backgroundColor: '#FFFFFF',
padding: 20,
borderRadius: 20,
marginBottom: 32,
},
contactTitle: {
fontSize: 14,
fontWeight: '700',
color: '#94A3B8',
textTransform: 'uppercase',
marginBottom: 16,
letterSpacing: 0.5,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatarPlaceholder: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#F1F5F9',
justifyContent: 'center',
alignItems: 'center',
},
avatarInitials: {
fontSize: 18,
fontWeight: '700',
color: '#64748B',
},
contactName: {
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
},
contactRole: {
fontSize: 13,
color: '#64748B',
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 32 : 20,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
})

View File

@@ -1,70 +1,60 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { View, Text, FlatList, RefreshControl, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useStellenListe } from '@/hooks/useStellen'
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({})
const { data, isLoading, refetch, isRefetching } = useStellenListe()
const totalStellen = data?.reduce((sum, s) => sum + s.stellenAnz, 0) ?? 0
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<SafeAreaView style={styles.safeArea} 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 style={styles.header}>
<View style={styles.titleRow}>
<View style={styles.titleBlock}>
<Text style={styles.screenTitle}>Lehrlingsbörse</Text>
<Text style={styles.subtitle}>Ausbildungsplätze in deiner Innung</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>
{/* Counter */}
{totalStellen > 0 && (
<View style={styles.counter}>
<Text style={styles.counterNumber}>{totalStellen}</Text>
<Text style={styles.counterLabel}>Stellen</Text>
</View>
)}
</View>
</View>
<View style={styles.divider} />
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
onPress={() => router.push(`/(app)/stellen/${item.id}` as never)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
icon="school-outline"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
subtitle="Aktuell keine Ausbildungsplätze verfügbar"
/>
}
/>
@@ -72,3 +62,69 @@ export default function StellenScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 16,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
titleBlock: {
flex: 1,
},
screenTitle: {
fontSize: 26,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
subtitle: {
fontSize: 13,
color: '#64748B',
marginTop: 2,
},
counter: {
backgroundColor: '#003B7E',
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 8,
alignItems: 'center',
minWidth: 56,
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
counterNumber: {
color: '#FFFFFF',
fontSize: 22,
fontWeight: '800',
lineHeight: 24,
},
counterLabel: {
color: 'rgba(255,255,255,0.75)',
fontSize: 9,
fontWeight: '600',
letterSpacing: 0.5,
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
padding: 16,
gap: 10,
},
})