stadtwerke/innungsapp/apps/mobile/app/(app)/profil/index.tsx

572 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, ActivityIndicator, Alert } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { useAuth } from '@/hooks/useAuth'
import { trpc } from '@/lib/trpc'
import { authClient } from '@/lib/auth-client'
const AVATAR_PALETTES = [
{ bg: '#003B7E', text: '#FFFFFF' },
{ bg: '#1D4ED8', text: '#FFFFFF' },
{ bg: '#059669', text: '#FFFFFF' },
{ bg: '#4338CA', text: '#FFFFFF' },
{ bg: '#B45309', text: '#FFFFFF' },
{ bg: '#0F766E', text: '#FFFFFF' },
]
function getInitials(name: string) {
return name.split(' ').slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')
}
function getPalette(name: string) {
if (!name) return AVATAR_PALETTES[0]
return AVATAR_PALETTES[name.charCodeAt(0) % AVATAR_PALETTES.length]
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
)
}
export default function ProfilScreen() {
const { signOut } = useAuth()
const router = useRouter()
const utils = trpc.useUtils()
const { data: me } = trpc.members.me.useQuery()
const { data: unread } = trpc.messages.unreadCount.useQuery(undefined, { refetchInterval: 15_000 })
const [showPersonal, setShowPersonal] = useState(false)
const [showBetrieb, setShowBetrieb] = useState(false)
const [showSicherheit, setShowSicherheit] = useState(false)
const [showMitteilungen, setShowMitteilungen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isEditingBetrieb, setIsEditingBetrieb] = useState(false)
const [isChangingPassword, setIsChangingPassword] = useState(false)
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' })
const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [editForm, setEditForm] = useState({ name: '', email: '', telefon: '', ort: '' })
const [editBetriebForm, setEditBetriebForm] = useState({ betrieb: '', sparte: '', istAusbildungsbetrieb: false })
const { mutate: updateMe, isPending: isUpdating } = trpc.members.updateMe.useMutation({
onSuccess: () => {
utils.members.me.invalidate()
setIsEditing(false)
Alert.alert('Erfolg', 'Profil wurde aktualisiert.')
},
onError: () => {
Alert.alert('Fehler', 'Profil konnte nicht aktualisiert werden.')
},
})
const handleEditPersonal = () => {
if (!me) return
setEditForm({
name: me.name || '',
email: me.email || '',
telefon: me.telefon || '',
ort: me.ort || '',
})
setIsEditing(true)
setShowPersonal(true)
}
const handleEditBetrieb = () => {
if (!me) return
setEditBetriebForm({
betrieb: me.betrieb || '',
sparte: me.sparte || '',
istAusbildungsbetrieb: me.istAusbildungsbetrieb ?? false,
})
setIsEditingBetrieb(true)
setShowBetrieb(true)
}
const handleSavePersonal = () => {
updateMe({
name: editForm.name,
email: editForm.email,
telefon: editForm.telefon,
ort: editForm.ort,
})
}
const handleSaveBetrieb = () => {
updateMe({
betrieb: editBetriebForm.betrieb,
sparte: editBetriebForm.sparte,
istAusbildungsbetrieb: editBetriebForm.istAusbildungsbetrieb,
})
setIsEditingBetrieb(false)
}
const name = me?.name ?? ''
const initials = getInitials(name)
const palette = getPalette(name)
const role = me?.org?.name ? 'Mitglied' : 'Mitglied'
const unreadCount = unread?.count ?? 0
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
{/* HERO */}
<View style={styles.hero}>
<View style={styles.avatarWrap}>
{/* Avatar — View+Text statt nur Text, damit Initials wirklich mittig */}
<View style={[styles.avatarCircle, { backgroundColor: palette.bg }]}>
<Text style={[styles.avatarText, { color: palette.text }]}>{initials}</Text>
</View>
<View style={styles.settingsBtn}>
<Ionicons name="settings-outline" size={15} color="#64748B" />
</View>
</View>
<Text style={styles.name}>{name || ''}</Text>
<Text style={styles.role}>{me?.org?.name ?? 'Innung'}</Text>
<View style={styles.badgesRow}>
{me?.status === 'aktiv' && (
<View style={styles.statusBadge}>
<Text style={styles.statusBadgeText}>Aktiv</Text>
</View>
)}
<View style={[styles.statusBadge, styles.verifyBadge]}>
<Text style={[styles.statusBadgeText, styles.verifyBadgeText]}>Verifiziert</Text>
</View>
</View>
</View>
<Text style={styles.sectionTitle}>Mein Account</Text>
<View style={styles.menuCard}>
{/* PERSÖNLICHE DATEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => {
if (showPersonal) {
setShowPersonal(false)
setIsEditing(false)
} else {
setShowPersonal(true)
}
}}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="person-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Persönliche Daten</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{showPersonal && !isEditing && (
<TouchableOpacity onPress={handleEditPersonal} style={{ marginRight: 12, padding: 4 }}>
<Ionicons name="create-outline" size={18} color="#2563EB" />
</TouchableOpacity>
)}
<Ionicons name={showPersonal ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
{showPersonal && (
<View style={styles.expandedCard}>
{isEditing ? (
<>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Name</Text>
<TextInput style={styles.input} value={editForm.name} onChangeText={t => setEditForm(f => ({ ...f, name: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>E-Mail</Text>
<TextInput style={styles.input} value={editForm.email} keyboardType="email-address" autoCapitalize="none" onChangeText={t => setEditForm(f => ({ ...f, email: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Telefon</Text>
<TextInput style={styles.input} value={editForm.telefon} keyboardType="phone-pad" onChangeText={t => setEditForm(f => ({ ...f, telefon: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Ort</Text>
<TextInput style={styles.input} value={editForm.ort} onChangeText={t => setEditForm(f => ({ ...f, ort: t }))} />
</View>
<View style={styles.editActionRow}>
<TouchableOpacity style={styles.cancelBtn} onPress={() => setIsEditing(false)}>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={handleSavePersonal} disabled={isUpdating}>
{isUpdating ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.saveBtnText}>Speichern</Text>}
</TouchableOpacity>
</View>
</>
) : (
<>
<InfoRow label="Name" value={me?.name} />
<InfoRow label="E-Mail" value={me?.email} />
<InfoRow label="Telefon" value={me?.telefon} />
<InfoRow label="Ort" value={me?.ort} />
<InfoRow label="Mitglied seit" value={me?.seit ? String(me.seit) : null} />
{!me?.name && (
<Text style={styles.emptyHint}>Keine Daten vorhanden.</Text>
)}
</>
)}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* BETRIEBSDATEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => {
if (showBetrieb) {
setShowBetrieb(false)
setIsEditingBetrieb(false)
} else {
setShowBetrieb(true)
}
}}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="business-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Betriebsdaten</Text>
</View>
<View style={styles.menuRight}>
{showBetrieb && !isEditingBetrieb && (
<TouchableOpacity onPress={handleEditBetrieb} style={{ marginRight: 12, padding: 4 }}>
<Ionicons name="create-outline" size={18} color="#2563EB" />
</TouchableOpacity>
)}
<Ionicons name={showBetrieb ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
{showBetrieb && (
<View style={styles.expandedCard}>
{isEditingBetrieb ? (
<>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Betrieb</Text>
<TextInput style={styles.input} value={editBetriebForm.betrieb} onChangeText={t => setEditBetriebForm(f => ({ ...f, betrieb: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Sparte</Text>
<TextInput style={styles.input} value={editBetriebForm.sparte} onChangeText={t => setEditBetriebForm(f => ({ ...f, sparte: t }))} />
</View>
<TouchableOpacity
style={[styles.infoRowEdit, { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 10 }]}
onPress={() => setEditBetriebForm(f => ({ ...f, istAusbildungsbetrieb: !f.istAusbildungsbetrieb }))}
>
<Text style={styles.infoLabel}>Ausbildungsbetrieb</Text>
<Ionicons name={editBetriebForm.istAusbildungsbetrieb ? 'checkbox' : 'square-outline'} size={20} color="#2563EB" />
</TouchableOpacity>
<View style={styles.editActionRow}>
<TouchableOpacity style={styles.cancelBtn} onPress={() => setIsEditingBetrieb(false)}>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={handleSaveBetrieb} disabled={isUpdating}>
{isUpdating ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.saveBtnText}>Speichern</Text>}
</TouchableOpacity>
</View>
</>
) : (
<>
<InfoRow label="Betrieb" value={me?.betrieb} />
<InfoRow label="Sparte" value={me?.sparte} />
<InfoRow label="Ausbildung" value={me?.istAusbildungsbetrieb ? 'Ja' : 'Nein'} />
{!me?.betrieb && !me?.sparte && (
<Text style={styles.emptyHint}>Keine Betriebsdaten vorhanden.</Text>
)}
</>
)}
</View>
)}
{!showBetrieb && (me?.betrieb || me?.sparte) && (
<View style={styles.subInfo}>
{me?.betrieb && <Text style={styles.subInfoText}>{me.betrieb}</Text>}
{me?.sparte && <Text style={styles.subInfoMuted}>{me.sparte}</Text>}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* MITTEILUNGEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => setShowMitteilungen((v) => !v)}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="notifications-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Mitteilungen</Text>
</View>
<View style={styles.menuRight}>
{unreadCount > 0 && (
<View style={styles.rowBadgeAlert}>
<Text style={[styles.rowBadgeText, { color: '#fff' }]}>{unreadCount}</Text>
</View>
)}
<Ionicons name={showMitteilungen ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
{showMitteilungen && (
<View style={styles.expandedCard}>
{unreadCount > 0 ? (
<TouchableOpacity
style={styles.mitteilungenAction}
onPress={() => router.push('/(app)/chat')}
>
<Ionicons name="chatbubbles-outline" size={20} color="#2563EB" />
<View style={{ flex: 1 }}>
<Text style={styles.mitteilungenTitle}>
{unreadCount} ungelesene Nachricht{unreadCount > 1 ? 'en' : ''}
</Text>
<Text style={styles.mitteilungenSub}>Direkt zu den Nachrichten</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</TouchableOpacity>
) : (
<Text style={styles.emptyHint}>Keine neuen Mitteilungen.</Text>
)}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* SICHERHEIT */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => setShowSicherheit(!showSicherheit)}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="shield-checkmark-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Sicherheit & Login</Text>
</View>
<Ionicons name={showSicherheit ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</TouchableOpacity>
{showSicherheit && (
<View style={styles.expandedCard}>
<InfoRow label="Login-E-Mail" value={me?.email} />
<InfoRow label="Account Status" value={me?.status === 'aktiv' ? 'Aktiviert' : 'Inaktiv'} />
{!isChangingPassword ? (
<TouchableOpacity
style={[styles.saveBtn, { marginTop: 10, width: '100%' }]}
onPress={() => {
setPasswordForm({ current: '', next: '', confirm: '' })
setPasswordError('')
setIsChangingPassword(true)
}}
>
<Text style={styles.saveBtnText}>Passwort ändern</Text>
</TouchableOpacity>
) : (
<View style={{ marginTop: 10, gap: 8 }}>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Aktuelles Passwort</Text>
<TextInput
style={styles.input}
value={passwordForm.current}
onChangeText={t => setPasswordForm(f => ({ ...f, current: t }))}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Neues Passwort</Text>
<TextInput
style={styles.input}
value={passwordForm.next}
onChangeText={t => setPasswordForm(f => ({ ...f, next: t }))}
secureTextEntry
placeholder="Mindestens 8 Zeichen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Wiederholen</Text>
<TextInput
style={styles.input}
value={passwordForm.confirm}
onChangeText={t => setPasswordForm(f => ({ ...f, confirm: t }))}
secureTextEntry
placeholder="Neues Passwort wiederholen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
{!!passwordError && (
<Text style={styles.passwordError}>{passwordError}</Text>
)}
<View style={styles.editActionRow}>
<TouchableOpacity
style={styles.cancelBtn}
onPress={() => setIsChangingPassword(false)}
disabled={passwordLoading}
>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveBtn}
disabled={passwordLoading}
onPress={async () => {
setPasswordError('')
if (!passwordForm.current) {
setPasswordError('Bitte aktuelles Passwort eingeben.')
return
}
if (passwordForm.next.length < 8) {
setPasswordError('Das neue Passwort muss mindestens 8 Zeichen haben.')
return
}
if (passwordForm.next !== passwordForm.confirm) {
setPasswordError('Die Passwörter stimmen nicht überein.')
return
}
setPasswordLoading(true)
const result = await authClient.changePassword({
currentPassword: passwordForm.current,
newPassword: passwordForm.next,
})
setPasswordLoading(false)
if (result.error) {
setPasswordError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
return
}
setIsChangingPassword(false)
Alert.alert('Erfolg', 'Passwort wurde erfolgreich geändert.')
}}
>
{passwordLoading
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.saveBtnText}>Speichern</Text>
}
</TouchableOpacity>
</View>
</View>
)}
</View>
)}
</View>
{/* LOGOUT */}
<TouchableOpacity style={styles.logoutBtn} activeOpacity={0.84} onPress={() => void signOut()}>
<Ionicons name="log-out-outline" size={20} color="#B91C1C" />
<Text style={styles.logoutText}>Abmelden</Text>
</TouchableOpacity>
<Text style={styles.footer}>InnungsApp Version 2.4.0</Text>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F8FAFC' },
content: { paddingHorizontal: 18, paddingBottom: 30, gap: 14 },
// Hero
hero: {
backgroundColor: '#FFFFFF', alignItems: 'center',
paddingTop: 24, paddingBottom: 18, borderRadius: 22,
borderWidth: 1, borderColor: '#E2E8F0', marginTop: 8,
},
avatarWrap: { position: 'relative' },
avatarCircle: {
width: 94, height: 94, borderRadius: 47,
alignItems: 'center', justifyContent: 'center',
borderWidth: 4, borderColor: '#FFFFFF',
shadowColor: '#000', shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12, shadowRadius: 8, elevation: 3,
},
avatarText: { fontSize: 34, fontWeight: '800', lineHeight: 40, includeFontPadding: false },
settingsBtn: {
position: 'absolute', right: 0, bottom: 2,
width: 30, height: 30, borderRadius: 15,
backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#E2E8F0',
alignItems: 'center', justifyContent: 'center',
},
name: { marginTop: 14, fontSize: 24, fontWeight: '800', color: '#0F172A' },
role: { marginTop: 2, fontSize: 12, fontWeight: '700', letterSpacing: 0.5, color: '#64748B', textTransform: 'uppercase' },
badgesRow: { marginTop: 10, flexDirection: 'row', gap: 8 },
statusBadge: { backgroundColor: '#DCFCE7', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 },
statusBadgeText: { color: '#166534', fontSize: 11, fontWeight: '700' },
verifyBadge: { backgroundColor: '#DBEAFE' },
verifyBadgeText: { color: '#1D4ED8' },
// Section
sectionTitle: {
marginTop: 2, paddingLeft: 2, fontSize: 11,
textTransform: 'uppercase', letterSpacing: 1.1,
color: '#94A3B8', fontWeight: '800',
},
// Menu card
menuCard: { backgroundColor: '#FFFFFF', borderRadius: 18, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden' },
menuRow: { paddingHorizontal: 14, paddingVertical: 13, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
menuRowBorderOnly: { height: 1, backgroundColor: '#F1F5F9', marginHorizontal: 14 },
menuLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 },
menuIcon: { width: 36, height: 36, borderRadius: 11, backgroundColor: '#F1F5F9', alignItems: 'center', justifyContent: 'center' },
menuLabel: { fontSize: 14, fontWeight: '700', color: '#1E293B' },
menuRight: { flexDirection: 'row', alignItems: 'center', gap: 7 },
rowBadgeActive: { backgroundColor: '#DCFCE7', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999 },
rowBadgeAlert: { backgroundColor: '#EF4444', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999 },
rowBadgeText: { fontSize: 10, fontWeight: '700' },
// Expanded card
expandedCard: { backgroundColor: '#F8FAFC', marginHorizontal: 14, marginBottom: 12, borderRadius: 12, padding: 12, gap: 6 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 5, borderBottomWidth: 1, borderBottomColor: '#E2E8F0' },
infoLabel: { fontSize: 12, color: '#64748B', fontWeight: '600', width: 100 },
infoValue: { fontSize: 13, color: '#0F172A', fontWeight: '500', flex: 1, textAlign: 'right' },
emptyHint: { fontSize: 13, color: '#94A3B8', textAlign: 'center', paddingVertical: 8 },
passwordError: { fontSize: 12, color: '#B91C1C', backgroundColor: '#FEF2F2', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
infoRowEdit: { paddingTop: 6, paddingBottom: 2, borderBottomWidth: 1, borderBottomColor: '#E2E8F0', marginTop: 2 },
input: { fontSize: 13, color: '#0F172A', fontWeight: '500', paddingVertical: 4, paddingHorizontal: 0, marginTop: 2 },
editActionRow: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10, marginTop: 14 },
cancelBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, backgroundColor: '#E2E8F0' },
cancelBtnText: { color: '#475569', fontWeight: '600', fontSize: 13 },
saveBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, backgroundColor: '#2563EB', minWidth: 90, alignItems: 'center' },
saveBtnText: { color: '#FFFFFF', fontWeight: '600', fontSize: 13 },
// Sub info (Betrieb)
subInfo: { paddingHorizontal: 14, paddingBottom: 12, gap: 4 },
subInfoText: { fontSize: 13, fontWeight: '600', color: '#334155' },
subInfoMuted: { fontSize: 12, color: '#64748B' },
ausbildungPill: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
ausbildungText: { fontSize: 11, color: '#15803D', fontWeight: '600' },
// Mitteilungen action
mitteilungenAction: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 4 },
mitteilungenTitle: { fontSize: 13, fontWeight: '700', color: '#1E293B' },
mitteilungenSub: { fontSize: 11, color: '#64748B', marginTop: 1 },
// Logout
logoutBtn: {
marginTop: 4, backgroundColor: '#FEF2F2', borderRadius: 14,
borderWidth: 1, borderColor: '#FECACA', paddingVertical: 14,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
},
logoutText: { color: '#B91C1C', fontSize: 14, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.8 },
footer: { textAlign: 'center', marginTop: 4, fontSize: 10, fontWeight: '700', letterSpacing: 1, color: '#94A3B8', textTransform: 'uppercase' },
})