refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,3 @@
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key

27
apps/manacore/apps/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
# firebase/supabase/vexo
.env
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# Local Netlify folder
.netlify

View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View file

@ -0,0 +1,60 @@
{
"expo": {
"name": "manacore",
"slug": "manacore",
"version": "1.0.0",
"scheme": "manacore",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-dev-launcher",
{
"launchMode": "most-recent"
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tilljs.manacore",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.manacore"
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "59ec0181-b120-4728-bf80-82623b831dda"
}
},
"owner": "memoro"
}
}

View file

@ -0,0 +1,29 @@
import { Tabs } from 'expo-router';
import { TabBarIcon } from '~/components/TabBarIcon';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: 'black',
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View file

@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
import { Container } from '~/components/Container';
import { ScreenContent } from '~/components/ScreenContent';
export default function Home() {
return (
<>
<Stack.Screen options={{ title: 'Tab One' }} />
<Container>
<ScreenContent path="app/(drawer)/(tabs)/index.tsx" title="Tab One" />
</Container>
</>
);
}

View file

@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
import { Container } from '~/components/Container';
import { ScreenContent } from '~/components/ScreenContent';
export default function Home() {
return (
<>
<Stack.Screen options={{ title: 'Tab Two' }} />
<Container>
<ScreenContent path="app/(drawer)/(tabs)/two.tsx" title="Tab Two" />
</Container>
</>
);
}

View file

@ -0,0 +1,114 @@
import { Ionicons, MaterialIcons, FontAwesome5 } from '@expo/vector-icons';
import { Link } from 'expo-router';
import { Drawer } from 'expo-router/drawer';
import { HeaderButton } from '../../components/HeaderButton';
import { useTheme } from '~/utils/themeContext';
const DrawerLayout = () => {
const { isDarkMode } = useTheme();
return (
<Drawer
screenOptions={{
headerStyle: {
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
headerTintColor: isDarkMode ? '#F9FAFB' : '#1F2937',
drawerStyle: {
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
},
drawerActiveTintColor: '#0055FF',
drawerInactiveTintColor: isDarkMode ? '#9CA3AF' : '#6B7280',
}}
>
<Drawer.Screen
name="index"
options={{
headerTitle: 'Home',
drawerLabel: 'Home',
drawerIcon: ({ size, color }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="(tabs)"
options={{
headerTitle: 'Tabs',
drawerLabel: 'Tabs',
drawerIcon: ({ size, color }) => (
<MaterialIcons name="border-bottom" size={size} color={color} />
),
headerRight: () => (
<Link href="/modal" asChild>
<HeaderButton />
</Link>
),
}}
/>
<Drawer.Screen
name="send-mana"
options={{
headerTitle: 'Mana senden',
drawerLabel: 'Mana senden',
drawerIcon: ({ size, color }) => (
<FontAwesome5 name="hand-holding-heart" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="get-mana"
options={{
headerTitle: 'Mana erhalten',
drawerLabel: 'Mana erhalten',
drawerIcon: ({ size, color }) => (
<FontAwesome5 name="shopping-cart" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="apps"
options={{
headerTitle: 'Apps',
drawerLabel: 'Apps',
drawerIcon: ({ size, color }) => (
<FontAwesome5 name="rocket" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="organizations/index"
options={{
headerTitle: 'Organisationen',
drawerLabel: 'Organisationen',
drawerIcon: ({ size, color }) => (
<FontAwesome5 name="building" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="teams/index"
options={{
headerTitle: 'Teams',
drawerLabel: 'Teams',
drawerIcon: ({ size, color }) => (
<FontAwesome5 name="users" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="settings"
options={{
headerTitle: 'Einstellungen',
drawerLabel: 'Einstellungen',
drawerIcon: ({ size, color }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Drawer>
);
};
export default DrawerLayout;

View file

@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react';
import { Stack } from 'expo-router';
import { ScrollView, Text, View, Image, TouchableOpacity, ActivityIndicator, Alert, Platform, Linking } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import { useTheme } from '../../utils/themeContext';
import { supabase } from '../../utils/supabase';
// Interface für die Satellite-Daten
interface Satellite {
id: string;
name: string;
description: string;
brand_logo: string;
created_at: string;
link_web?: string;
link_ios?: string;
link_android?: string;
}
export default function Apps() {
const { isDarkMode } = useTheme();
const [satellites, setSatellites] = useState<Satellite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSatellites();
}, []);
const fetchSatellites = async () => {
try {
setLoading(true);
// Hole die Satellites-Daten aus der Supabase-Datenbank
const { data, error } = await supabase
.from('satellites')
.select('*')
.order('name');
if (error) {
throw error;
}
if (data) {
console.log('Satellites geladen:', data.length);
setSatellites(data);
}
} catch (error) {
console.error('Fehler beim Laden der Satellites:', error);
Alert.alert(
'Fehler',
'Beim Laden der Apps ist ein Fehler aufgetreten. Bitte versuche es später erneut.'
);
} finally {
setLoading(false);
}
};
const handleAppClick = (satellite: Satellite) => {
// Öffne die App-Details in einem Alert
console.log('App angeklickt:', satellite.name);
Alert.alert(
satellite.name,
satellite.description,
[{ text: 'OK' }]
);
};
const openAppLink = async (satellite: Satellite) => {
try {
let linkToOpen: string | undefined;
// Wähle den entsprechenden Link basierend auf der Plattform
if (Platform.OS === 'ios') {
linkToOpen = satellite.link_ios;
} else if (Platform.OS === 'android') {
linkToOpen = satellite.link_android;
} else {
// Für Web oder wenn die plattformspezifischen Links nicht verfügbar sind
linkToOpen = satellite.link_web;
}
// Fallback: Wenn kein plattformspezifischer Link vorhanden ist, versuche den Web-Link
if (!linkToOpen) {
linkToOpen = satellite.link_web;
}
// Wenn kein Link verfügbar ist, zeige eine Meldung an
if (!linkToOpen) {
Alert.alert(
'Bald verfügbar',
`${satellite.name} wird bald verfügbar sein.`,
[{ text: 'OK' }]
);
return;
}
// Prüfe, ob der Link geöffnet werden kann
const canOpen = await Linking.canOpenURL(linkToOpen);
if (canOpen) {
// Öffne den Link
await Linking.openURL(linkToOpen);
} else {
Alert.alert(
'Fehler',
`Der Link für ${satellite.name} kann nicht geöffnet werden.`,
[{ text: 'OK' }]
);
}
} catch (error) {
console.error('Fehler beim Öffnen des Links:', error);
Alert.alert(
'Fehler',
'Beim Öffnen des Links ist ein Fehler aufgetreten.',
[{ text: 'OK' }]
);
}
};
return (
<>
<Stack.Screen
options={{
title: 'Apps',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView className="flex-1">
<View className="mx-2.5 my-4">
<Text className={`text-2xl font-bold mb-2 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Verfügbare Apps
</Text>
<Text className={`text-base mb-6 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Entdecke Apps, die mit Manacore verbunden werden können
</Text>
{loading ? (
<View className={`flex-1 justify-center items-center p-10 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg shadow`}>
<ActivityIndicator size="large" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`mt-4 text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Lade Apps...
</Text>
</View>
) : satellites.length === 0 ? (
<View className={`p-8 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg shadow items-center`}>
<FontAwesome5 name="rocket" size={50} color={isDarkMode ? '#4B5563' : '#9CA3AF'} />
<Text className={`mt-4 text-lg font-medium text-center ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Keine Apps verfügbar
</Text>
<Text className={`mt-2 text-sm text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Derzeit sind keine Apps verfügbar. Schaue später noch einmal vorbei.
</Text>
</View>
) : (
<View className="flex-row flex-wrap justify-between">
{satellites.map((satellite) => (
<TouchableOpacity
key={satellite.id}
className={`w-[48%] mb-4 rounded-xl overflow-hidden shadow ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}
onPress={() => handleAppClick(satellite)}
activeOpacity={0.7}
>
<View className="p-4 items-center">
{satellite.brand_logo ? (
<Image
source={{ uri: satellite.brand_logo }}
className="w-16 h-16 rounded-lg mb-3"
resizeMode="contain"
// Fallback für den Fall, dass das Bild nicht geladen werden kann
onError={() => console.log('Fehler beim Laden des Logos:', satellite.brand_logo)}
/>
) : (
<View className="w-16 h-16 rounded-lg mb-3 bg-gray-200 items-center justify-center">
<FontAwesome5 name="rocket" size={24} color={isDarkMode ? '#4B5563' : '#9CA3AF'} />
</View>
)}
<Text className={`text-base font-bold text-center mb-1 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
{satellite.name}
</Text>
<Text
className={`text-xs text-center ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
numberOfLines={2}
ellipsizeMode="tail"
>
{satellite.description}
</Text>
</View>
<TouchableOpacity
className={`py-2 w-full ${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'}`}
onPress={() => openAppLink(satellite)}
disabled={!satellite.link_web && !satellite.link_ios && !satellite.link_android}
>
<Text className="text-white text-center font-medium text-sm">
{satellite.link_web || satellite.link_ios || satellite.link_android ? 'Öffnen' : 'Bald verfügbar'}
</Text>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
)}
<View className={`mt-4 p-4 rounded-lg ${isDarkMode ? 'bg-gray-800' : 'bg-gray-100'}`}>
<Text className={`text-lg font-semibold mb-2 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Über Apps
</Text>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Apps sind Erweiterungen, die mit Manacore verbunden werden können, um zusätzliche Funktionen zu nutzen.
Jede App bietet spezifische Funktionen, die deine Manacore-Erfahrung verbessern.
</Text>
</View>
</View>
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Stack } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity, Image, Alert } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import { useTheme } from '../../utils/themeContext';
import { supabase } from '../../utils/supabase';
// Definiere die Mana-Pakete
const manaPacks = [
{
id: 'basic',
name: 'Basis Paket',
amount: 100,
price: '9,99 €',
description: '100 Mana-Punkte',
popular: false,
color: '#4F46E5', // Indigo
},
{
id: 'standard',
name: 'Standard Paket',
amount: 500,
price: '39,99 €',
description: '500 Mana-Punkte',
popular: true,
color: '#0055FF', // Blau
},
{
id: 'premium',
name: 'Premium Paket',
amount: 1500,
price: '99,99 €',
description: '1500 Mana-Punkte',
popular: false,
color: '#7C3AED', // Violett
},
{
id: 'enterprise',
name: 'Enterprise Paket',
amount: 5000,
price: '299,99 €',
description: '5000 Mana-Punkte',
popular: false,
color: '#10B981', // Grün
},
];
export default function GetMana() {
const { isDarkMode } = useTheme();
const [selectedPack, setSelectedPack] = useState<string | null>(null);
const [loading, setLoading] = useState<Record<string, boolean>>({});
const handlePurchase = async (packId: string) => {
// Setze den Loading-Status für dieses Paket
setLoading(prev => ({ ...prev, [packId]: true }));
try {
// Hier würde die tatsächliche Kauflogik implementiert werden
// z.B. Integration mit einem Zahlungsanbieter
// Simuliere einen API-Aufruf
await new Promise(resolve => setTimeout(resolve, 1500));
// Erfolgsbenachrichtigung
Alert.alert(
'Kauf erfolgreich',
`Du hast erfolgreich das ${manaPacks.find(p => p.id === packId)?.name} erworben!`,
[{ text: 'OK' }]
);
// Setze den ausgewählten Pack zurück
setSelectedPack(null);
} catch (error) {
console.error('Fehler beim Kauf:', error);
Alert.alert(
'Fehler',
'Beim Kauf ist ein Fehler aufgetreten. Bitte versuche es später erneut.',
[{ text: 'OK' }]
);
} finally {
// Setze den Loading-Status zurück
setLoading(prev => ({ ...prev, [packId]: false }));
}
};
return (
<>
<Stack.Screen
options={{
title: 'Mana erhalten',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView className="flex-1">
<View className="mx-2.5 my-4">
<Text className={`text-2xl font-bold mb-2 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Mana-Pakete
</Text>
<Text className={`text-base mb-6 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Wähle ein Paket, das zu deinen Bedürfnissen passt
</Text>
{manaPacks.map((pack) => (
<TouchableOpacity
key={pack.id}
className={`mb-4 rounded-xl overflow-hidden shadow-md ${
isDarkMode ? 'bg-gray-800' : 'bg-white'
} ${selectedPack === pack.id ? 'border-2 border-blue-500' : ''}`}
onPress={() => setSelectedPack(pack.id)}
activeOpacity={0.7}
>
{/* Beliebtheits-Badge */}
{pack.popular && (
<View className="absolute top-0 right-0 bg-orange-500 py-1 px-3 rounded-bl-lg z-10">
<Text className="text-white text-xs font-bold">Beliebt</Text>
</View>
)}
{/* Header */}
<View
className="p-4 w-full"
style={{ backgroundColor: pack.color }}
>
<Text className="text-white text-xl font-bold">{pack.name}</Text>
<Text className="text-white text-sm opacity-80">{pack.description}</Text>
</View>
{/* Preis und Menge */}
<View className="p-4">
<View className="flex-row justify-between items-center mb-4">
<View>
<Text className={`text-3xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
{pack.price}
</Text>
</View>
<View className="flex-row items-center">
<FontAwesome5 name="fire" size={20} color="#F59E0B" />
<Text className={`ml-2 text-xl font-semibold ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{pack.amount} Mana
</Text>
</View>
</View>
{/* Beschreibung */}
<View className="mb-4">
<View className="flex-row items-center mb-2">
<FontAwesome5 name="check-circle" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`ml-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{pack.description}
</Text>
</View>
</View>
{/* Kaufen-Button */}
<TouchableOpacity
className={`py-3 rounded-lg ${
selectedPack === pack.id
? 'bg-blue-600'
: isDarkMode
? 'bg-gray-700'
: 'bg-gray-200'
}`}
onPress={() => handlePurchase(pack.id)}
disabled={loading[pack.id]}
>
<Text
className={`text-center font-semibold ${
selectedPack === pack.id
? 'text-white'
: isDarkMode
? 'text-gray-300'
: 'text-gray-700'
}`}
>
{loading[pack.id] ? 'Wird verarbeitet...' : 'Jetzt kaufen'}
</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
))}
{/* Informationsbereich */}
<View className={`mt-4 p-4 rounded-lg ${isDarkMode ? 'bg-gray-800' : 'bg-gray-100'}`}>
<Text className={`text-lg font-semibold mb-2 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Über Mana
</Text>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Mana ist die Währung in unserer App, mit der du Aktionen durchführen und Funktionen freischalten kannst.
</Text>
</View>
</View>
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,132 @@
import { Stack, useRouter } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity, Pressable } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import DashboardStats from '../../components/DashboardStats';
import { useTheme } from '../../utils/themeContext';
// Extraktion der Feature-Liste für bessere Wartbarkeit
const FEATURES = [
{ icon: 'exchange-alt', text: 'Mana an andere Benutzer senden' },
{ icon: 'users', text: 'Teams und Organisationen verwalten' },
{ icon: 'chart-line', text: 'Ihre Mana-Nutzung verfolgen' },
{ icon: 'shopping-cart', text: 'Mana für Ihre Projekte erwerben' },
{ icon: 'rocket', text: 'Nützliche Apps entdecken' },
];
// Extraktion der Action-Buttons für bessere Wartbarkeit
const ActionButton = ({
onPress,
icon,
label,
colorClass
}: {
onPress: () => void;
icon: string;
label: string;
colorClass: string;
}) => (
<TouchableOpacity
className={`${colorClass} rounded-lg p-4 flex-row items-center justify-center shadow-md flex-1`}
onPress={onPress}
activeOpacity={0.8}
>
<FontAwesome5 name={icon} size={24} color="white" />
<Text className="text-white text-lg font-bold ml-3">{label}</Text>
</TouchableOpacity>
);
// Extraktion der Feature-Liste als Komponente
const FeatureList = ({ isDarkMode }: { isDarkMode: boolean }) => (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 mb-5 shadow`}>
<Text className={`text-lg font-bold mb-4 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Mit ManaCore können Sie:
</Text>
{FEATURES.map((feature, index) => (
<View key={index} className="flex-row items-center mb-3">
<FontAwesome5
name={feature.icon}
size={18}
color={isDarkMode ? '#60A5FA' : '#0055FF'}
className="mr-2.5 w-6"
/>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{feature.text}
</Text>
</View>
))}
</View>
);
export default function Home() {
const router = useRouter();
const { isDarkMode } = useTheme();
const navigateToSendMana = () => router.push('/send-mana');
const navigateToGetMana = () => router.push('/get-mana');
const navigateToApps = () => router.push('/apps');
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-gray-50'}`}>
<Stack.Screen
options={{
title: 'ManaCore',
headerShown: true,
}}
/>
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 16,
paddingBottom: 40
}}
>
{/* Welcome Section */}
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 mb-5 shadow`}>
<Text className={`text-2xl font-bold mb-2.5 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
Willkommen bei ManaCore
</Text>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} leading-6`}>
Teilen Sie Ihre Energie mit anderen und verwalten Sie Ihre Mana-Kredite.
</Text>
</View>
{/* Dashboard Stats */}
<DashboardStats />
{/* Action Buttons */}
<View className="flex-row justify-between mb-5">
<ActionButton
onPress={navigateToSendMana}
icon="hand-holding-heart"
label="Mana senden"
colorClass={`${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'} mr-2`}
/>
<ActionButton
onPress={navigateToGetMana}
icon="shopping-cart"
label="Mana erwerben"
colorClass={`${isDarkMode ? 'bg-green-700' : 'bg-green-600'} ml-2`}
/>
</View>
{/* Apps Button */}
<TouchableOpacity
className={`${isDarkMode ? 'bg-purple-700' : 'bg-purple-600'} rounded-lg p-5 mb-5 flex-row items-center justify-center shadow-md`}
onPress={navigateToApps}
activeOpacity={0.8}
>
<FontAwesome5 name="rocket" size={24} color="white" />
<Text className="text-white text-lg font-bold ml-4">Apps entdecken</Text>
</TouchableOpacity>
{/* Feature List */}
<FeatureList isDarkMode={isDarkMode} />
</ScrollView>
</View>
);
}
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich

View file

@ -0,0 +1,497 @@
import React, { useState, useEffect } from 'react';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity, TextInput, Alert, ActivityIndicator, Modal } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import { useTheme } from '../../../utils/themeContext';
import { supabase } from '../../../utils/supabase';
interface OrganizationDetails {
id: string;
name: string;
total_credits: number;
used_credits: number;
created_at: string;
team_count: number;
user_count: number;
}
export default function OrganizationDetails() {
const router = useRouter();
const { id: orgId, name: initialOrgName } = useLocalSearchParams<{ id: string, name: string }>();
const { isDarkMode } = useTheme();
const [orgName, setOrgName] = useState(initialOrgName || '');
const [isEditing, setIsEditing] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const [loading, setLoading] = useState(false);
const [deletingOrg, setDeletingOrg] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [orgDetails, setOrgDetails] = useState<OrganizationDetails | null>(null);
const [userRole, setUserRole] = useState<string>('');
const [loadingDetails, setLoadingDetails] = useState(true);
useEffect(() => {
if (initialOrgName) {
setOrgName(initialOrgName);
setNewOrgName(initialOrgName);
}
fetchOrganizationDetails();
}, [initialOrgName, orgId]);
const fetchOrganizationDetails = async () => {
if (!orgId) return;
try {
setLoadingDetails(true);
// Hole die Organisation
const { data: org, error: orgError } = await supabase
.from('organizations')
.select('id, name, total_credits, used_credits, created_at')
.eq('id', orgId)
.single();
if (orgError) throw orgError;
// Hole die Anzahl der Teams in dieser Organisation
const { count: teamCount, error: teamCountError } = await supabase
.from('teams')
.select('id', { count: 'exact', head: true })
.eq('organization_id', orgId);
if (teamCountError) throw teamCountError;
// Hole die Anzahl der Benutzer in dieser Organisation
const { data: userRoles, error: userRolesError } = await supabase
.from('user_roles')
.select('user_id')
.eq('organization_id', orgId);
if (userRolesError) throw userRolesError;
// Entferne Duplikate (ein Benutzer kann mehrere Rollen haben)
const uniqueUserIds = [...new Set(userRoles.map(role => role.user_id))];
// Hole die aktuelle Benutzerrolle
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
const { data: currentUserRoles, error: currentUserRolesError } = await supabase
.from('user_roles')
.select('roles(name)')
.eq('user_id', session.user.id)
.eq('organization_id', orgId);
// Supabase gibt die Daten in einem anderen Format zurück als erwartet
// Definiere den korrekten Typ für die Benutzerrolle
interface UserRoleWithRoles {
roles: {
name: string;
}
}
if (currentUserRolesError) throw currentUserRolesError;
// Finde die höchste Rolle
const roleHierarchy = {
'system_admin': 4,
'org_admin': 3,
'team_admin': 2,
'member': 1
};
let highestRole = 'member';
let highestRoleValue = 0;
// Typensichere Iteration über die Benutzerrollen
// Wir müssen die Daten zuerst in das erwartete Format konvertieren
currentUserRoles.forEach((userRole: any) => {
// Prüfe, ob die Rolle existiert und ein Name vorhanden ist
const roleName = userRole.roles?.name;
if (roleName && roleHierarchy[roleName as keyof typeof roleHierarchy] > highestRoleValue) {
highestRole = roleName;
highestRoleValue = roleHierarchy[roleName as keyof typeof roleHierarchy];
}
});
setUserRole(highestRole);
}
setOrgDetails({
...org,
team_count: teamCount || 0,
user_count: uniqueUserIds.length
});
setOrgName(org.name);
setNewOrgName(org.name);
} catch (error) {
console.error('Fehler beim Laden der Organisationsdetails:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden der Organisationsdetails aufgetreten.');
} finally {
setLoadingDetails(false);
}
};
const navigateBack = () => {
router.replace('/organizations');
};
const toggleEditMode = () => {
if (isEditing) {
setIsEditing(false);
} else {
setNewOrgName(orgName);
setIsEditing(true);
}
};
const updateOrganizationName = async () => {
if (!orgId || !newOrgName.trim() || newOrgName.trim() === orgName) {
setIsEditing(false);
return;
}
try {
setLoading(true);
const { error } = await supabase
.from('organizations')
.update({ name: newOrgName.trim() })
.eq('id', orgId);
if (error) throw error;
setOrgName(newOrgName.trim());
setIsEditing(false);
Alert.alert('Erfolg', 'Der Organisationsname wurde erfolgreich aktualisiert.');
} catch (error) {
console.error('Fehler beim Aktualisieren des Organisationsnamens:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Aktualisieren des Organisationsnamens aufgetreten.');
} finally {
setLoading(false);
}
};
const deleteOrg = () => {
console.log('Delete organization button clicked, orgId:', orgId);
// Modal öffnen statt Alert anzeigen
setShowDeleteModal(true);
};
const confirmDelete = () => {
console.log('Löschen bestätigt für Organisation:', orgId);
setShowDeleteModal(false);
handleOrgDeletion();
};
const cancelDelete = () => {
console.log('Löschen abgebrochen');
setShowDeleteModal(false);
};
const handleOrgDeletion = async () => {
if (!orgId) return;
try {
setDeletingOrg(true);
console.log('Starte Löschvorgang für Organisation:', orgId);
// 1. Prüfe, ob es Teams in dieser Organisation gibt
const { count: teamCount, error: teamCountError } = await supabase
.from('teams')
.select('id', { count: 'exact', head: true })
.eq('organization_id', orgId);
if (teamCountError) throw teamCountError;
if (teamCount && teamCount > 0) {
Alert.alert(
'Fehler',
'Diese Organisation enthält noch Teams. Bitte lösche zuerst alle Teams, bevor du die Organisation löschst.'
);
return;
}
// 2. Lösche alle Benutzerrollen für diese Organisation
console.log('Lösche Benutzerrollen für Organisation:', orgId);
const { error: userRolesError } = await supabase
.from('user_roles')
.delete()
.eq('organization_id', orgId);
if (userRolesError) throw userRolesError;
// 3. Lösche die Organisation
console.log('Lösche Organisation:', orgId);
const { error: orgError } = await supabase
.from('organizations')
.delete()
.eq('id', orgId);
if (orgError) throw orgError;
console.log('Organisation erfolgreich gelöscht');
// Erfolgsmeldung anzeigen und zurück zur Organisationsliste navigieren
Alert.alert(
'Erfolg',
'Die Organisation wurde erfolgreich gelöscht.',
[{
text: 'OK',
onPress: () => {
console.log('Navigating back to organizations list');
// Zurück zur Organisationsliste navigieren
router.replace('/organizations');
}
}]
);
// Auch ohne Klick auf OK zurück zur Organisationsliste navigieren (nach kurzer Verzögerung)
setTimeout(() => {
router.replace('/organizations');
}, 1000);
} catch (error: any) {
console.error('Fehler beim Löschen der Organisation:', error);
// Detaillierte Fehlermeldung anzeigen
Alert.alert(
'Fehler',
`Es ist ein Fehler beim Löschen der Organisation aufgetreten: ${error?.message || JSON.stringify(error)}`,
[{ text: 'OK' }]
);
} finally {
setDeletingOrg(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const getRoleName = (role: string) => {
switch (role) {
case 'system_admin':
return 'System-Admin';
case 'org_admin':
return 'Organisations-Admin';
case 'team_admin':
return 'Team-Admin';
default:
return 'Mitglied';
}
};
return (
<>
<Stack.Screen
options={{
title: orgName || 'Organisations-Details',
headerLargeTitle: true,
}}
/>
{/* Lösch-Bestätigungsmodal */}
<Modal
animationType="fade"
transparent={true}
visible={showDeleteModal}
onRequestClose={cancelDelete}
>
<View className="flex-1 justify-center items-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View className={`m-5 p-6 rounded-xl shadow-lg ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`} style={{ width: '80%' }}>
<View className="items-center mb-4">
<FontAwesome5 name="exclamation-triangle" size={40} color="#EF4444" />
</View>
<Text className={`text-xl font-bold mb-2 text-center ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
Organisation löschen
</Text>
<Text className={`text-base mb-6 text-center ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Möchtest du die Organisation "{orgName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</Text>
<View className="flex-row justify-between">
<TouchableOpacity
className={`flex-1 py-3 rounded-lg mr-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`}
onPress={cancelDelete}
>
<Text className={`text-center font-medium ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-1 py-3 rounded-lg ml-2 bg-red-600"
onPress={confirmDelete}
disabled={deletingOrg}
>
{deletingOrg ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-center font-medium text-white">Löschen</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
<Container>
<ScrollView className="flex-1">
<View className="flex-row justify-between mx-2.5 my-2.5">
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg border ${isDarkMode ? 'border-blue-500' : 'border-blue-600'}`}
onPress={navigateBack}
>
<FontAwesome5 name="arrow-left" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2" />
<Text className={`${isDarkMode ? 'text-blue-400' : 'text-blue-600'} font-semibold text-sm`}>Meine Organisationen</Text>
</TouchableOpacity>
{(userRole === 'org_admin' || userRole === 'system_admin') && (
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg ${deletingOrg ? 'bg-gray-500' : 'bg-red-600'}`}
onPress={deleteOrg}
disabled={deletingOrg}
activeOpacity={0.7}
>
{deletingOrg ? (
<ActivityIndicator size="small" color="white" />
) : (
<>
<FontAwesome5 name="trash-alt" size={16} color="white" className="mr-2" />
<Text className="text-white font-semibold text-sm">Organisation löschen</Text>
</>
)}
</TouchableOpacity>
)}
</View>
{loadingDetails ? (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<ActivityIndicator size="large" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Lade Organisationsdetails...</Text>
</View>
) : (
<>
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<View className="flex-row justify-between items-center mb-4">
{isEditing ? (
<View className="flex-1 flex-row items-center">
<TextInput
className={`flex-1 h-12 border ${isDarkMode ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-300 bg-gray-50 text-gray-800'} rounded-lg px-4 text-base`}
value={newOrgName}
onChangeText={setNewOrgName}
placeholder="Organisationsname"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
autoFocus
/>
<TouchableOpacity
className="ml-2 p-2"
onPress={updateOrganizationName}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
) : (
<FontAwesome5 name="check" size={20} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
)}
</TouchableOpacity>
<TouchableOpacity
className="ml-2 p-2"
onPress={toggleEditMode}
>
<FontAwesome5 name="times" size={20} color={isDarkMode ? '#F87171' : '#EF4444'} />
</TouchableOpacity>
</View>
) : (
<>
<View className="flex-row items-center">
<FontAwesome5 name="building" size={24} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-3" />
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{orgName}</Text>
</View>
{(userRole === 'org_admin' || userRole === 'system_admin') && (
<TouchableOpacity
className="p-2"
onPress={toggleEditMode}
>
<FontAwesome5 name="edit" size={18} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
</TouchableOpacity>
)}
</>
)}
</View>
{orgDetails && (
<>
<View className={`flex-row justify-between py-3 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Text className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Erstellt am</Text>
<Text className={`font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{formatDate(orgDetails.created_at)}</Text>
</View>
<View className={`flex-row justify-between py-3 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Text className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Teams</Text>
<Text className={`font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{orgDetails.team_count}</Text>
</View>
<View className={`flex-row justify-between py-3 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Text className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Mitglieder</Text>
<Text className={`font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{orgDetails.user_count}</Text>
</View>
<View className={`flex-row justify-between py-3 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Text className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Deine Rolle</Text>
<View className={`px-2 py-0.5 rounded-full ${userRole === 'system_admin' ? 'bg-red-600' : userRole === 'org_admin' ? 'bg-orange-500' : userRole === 'team_admin' ? 'bg-green-600' : 'bg-gray-600'}`}>
<Text className="text-xs font-medium text-white">{getRoleName(userRole)}</Text>
</View>
</View>
</>
)}
</View>
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<View className="flex-row items-center mb-4">
<FontAwesome5 name="coins" size={20} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2" />
<Text className={`text-xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Kredit-Übersicht</Text>
</View>
{orgDetails && (
<View className="flex-row justify-between">
<View className="flex-1 items-center p-3 bg-opacity-20 rounded-lg mr-2" style={{ backgroundColor: isDarkMode ? 'rgba(147, 197, 253, 0.1)' : 'rgba(0, 85, 255, 0.1)' }}>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-1`}>Gesamt</Text>
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{orgDetails.total_credits}</Text>
</View>
<View className="flex-1 items-center p-3 bg-opacity-20 rounded-lg mx-1" style={{ backgroundColor: isDarkMode ? 'rgba(147, 197, 253, 0.1)' : 'rgba(0, 85, 255, 0.1)' }}>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-1`}>Verwendet</Text>
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{orgDetails.used_credits}</Text>
</View>
<View className="flex-1 items-center p-3 bg-opacity-20 rounded-lg ml-2" style={{ backgroundColor: isDarkMode ? 'rgba(147, 197, 253, 0.1)' : 'rgba(0, 85, 255, 0.1)' }}>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-1`}>Verfügbar</Text>
<Text
className={`text-2xl font-bold ${orgDetails.total_credits - orgDetails.used_credits > 0 ? (isDarkMode ? 'text-green-400' : 'text-green-600') : (isDarkMode ? 'text-red-400' : 'text-red-600')}`}
>
{orgDetails.total_credits - orgDetails.used_credits}
</Text>
</View>
</View>
)}
</View>
{/* Hier könnte später eine Liste der Teams oder Mitglieder angezeigt werden */}
</>
)}
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,87 @@
import React, { useState, useRef } from 'react';
import { Stack, useRouter, useFocusEffect } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { useCallback } from 'react';
import { Container } from '~/components/Container';
import CreateOrganization from '../../../components/CreateOrganization';
import OrganizationList from '../../../components/OrganizationList';
import { useTheme } from '../../../utils/themeContext';
export default function Organizations() {
const [showCreateOrg, setShowCreateOrg] = useState(false);
const { isDarkMode } = useTheme();
const router = useRouter();
const organizationListRef = useRef<any>(null);
// Diese Funktion wird jedes Mal aufgerufen, wenn die Seite fokussiert wird
useFocusEffect(
useCallback(() => {
console.log('Organizations-Seite fokussiert, aktualisiere Liste');
// Wenn die Liste existiert, aktualisiere sie
if (organizationListRef.current) {
organizationListRef.current.refreshOrganizations();
}
}, [])
);
const handleOrgCreated = (orgId: string, orgName: string) => {
console.log('Organisation erstellt, navigiere zur Detailseite:', orgId, orgName);
// Zuerst zurück zur Organisationsliste wechseln
setShowCreateOrg(false);
// Dann zur Detailseite der neuen Organisation navigieren
// Verzögerung für stabilere Navigation
setTimeout(() => {
router.push({
pathname: '/organizations/[id]',
params: { id: orgId, name: orgName }
});
}, 300);
};
return (
<>
<Stack.Screen
options={{
title: 'Organisationen',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView className="flex-1">
{!showCreateOrg ? (
<>
<View className="flex-row justify-between items-center mx-2.5 my-2.5">
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Meine Organisationen</Text>
<TouchableOpacity
className={`${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'} flex-row items-center py-2 px-4 rounded-lg shadow`}
onPress={() => setShowCreateOrg(true)}
>
<FontAwesome5 name="plus" size={14} color="white" className="mr-2" />
<Text className="text-white font-semibold text-sm">Neue Organisation</Text>
</TouchableOpacity>
</View>
<OrganizationList hideTitle={true} ref={organizationListRef} />
</>
) : (
<>
<View className="flex-row justify-end mx-2.5 my-2.5">
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg border ${isDarkMode ? 'border-blue-500' : 'border-blue-600'}`}
onPress={() => setShowCreateOrg(false)}
>
<FontAwesome5 name="arrow-left" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2" />
<Text className={`${isDarkMode ? 'text-blue-400' : 'text-blue-600'} font-semibold text-sm`}>Zurück zu meinen Organisationen</Text>
</TouchableOpacity>
</View>
<CreateOrganization onOrgCreated={handleOrgCreated} />
</>
)}
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,34 @@
import React from 'react';
import { Stack } from 'expo-router';
import { ScrollView, StyleSheet } from 'react-native';
import { Container } from '~/components/Container';
import SendMana from '../../components/SendMana';
import { useTheme } from '../../utils/themeContext';
export default function SendManaScreen() {
const { isDarkMode } = useTheme();
const styles = StyleSheet.create({
scrollView: {
flex: 1,
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
});
return (
<>
<Stack.Screen
options={{
title: 'Mana senden',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView style={styles.scrollView}>
<SendMana />
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,407 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Stack } from 'expo-router';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import { useTheme, ThemeMode } from '~/utils/themeContext';
import { supabase } from '../../utils/supabase';
import { Session } from '@supabase/supabase-js';
interface Profile {
id: string;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
is_individual: boolean;
individual_quota: number;
individual_usage: number;
}
export default function SettingsScreen() {
const { themeMode, setThemeMode, isDarkMode } = useTheme();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(false);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [profile, setProfile] = useState<Profile | null>(null);
// Funktion zum Ändern des Theme-Modus
const changeTheme = (mode: ThemeMode) => {
setThemeMode(mode);
};
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) getProfile(session);
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) getProfile(session);
});
return () => subscription.unsubscribe();
}, []);
async function getProfile(currentSession: Session) {
try {
setLoading(true);
const { user } = currentSession;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
if (error) {
throw error;
}
if (data) {
setProfile(data);
setFirstName(data.first_name || '');
setLastName(data.last_name || '');
}
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Laden des Profils', error.message);
}
} finally {
setLoading(false);
}
}
async function updateProfile() {
if (!session) return;
try {
setLoading(true);
const { user } = session;
// Prüfen, ob das Profil bereits existiert
if (!profile) {
// Erstelle ein neues Profil, wenn es noch nicht existiert
const { error: insertError } = await supabase
.from('profiles')
.insert([
{
id: user.id,
first_name: firstName,
last_name: lastName,
is_individual: true, // Standardmäßig als Einzelnutzer
individual_quota: 0,
individual_usage: 0,
},
]);
if (insertError) throw insertError;
} else {
// Aktualisiere das bestehende Profil
const { error: updateError } = await supabase
.from('profiles')
.update({
first_name: firstName,
last_name: lastName,
updated_at: new Date(),
})
.eq('id', user.id);
if (updateError) throw updateError;
}
Alert.alert('Erfolg', 'Profil erfolgreich aktualisiert!');
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Aktualisieren des Profils', error.message);
}
} finally {
setLoading(false);
}
}
async function signOut() {
try {
setLoading(true);
const { error } = await supabase.auth.signOut();
if (error) throw error;
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Abmelden', error.message);
}
} finally {
setLoading(false);
}
};
// Dynamische Stile basierend auf dem aktuellen Theme
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
color: isDarkMode ? '#F9FAFB' : '#1F2937',
},
card: {
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: isDarkMode ? '#000000' : '#000000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: isDarkMode ? 0.5 : 0.1,
shadowRadius: 4,
elevation: 4,
},
themeButtonsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
},
themeButton: {
flex: 1,
padding: 12,
borderRadius: 8,
alignItems: 'center',
marginHorizontal: 4,
},
activeThemeButton: {
backgroundColor: isDarkMode ? '#3B82F6' : '#0055FF',
},
inactiveThemeButton: {
backgroundColor: isDarkMode ? '#374151' : '#F3F4F6',
},
themeButtonText: {
fontWeight: '500',
marginTop: 4,
color: isDarkMode ? '#F9FAFB' : '#1F2937',
},
activeThemeButtonText: {
color: '#FFFFFF',
},
// Profil-Stile
formGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 5,
fontWeight: '500',
color: isDarkMode ? '#E5E7EB' : '#374151',
},
value: {
fontSize: 16,
color: isDarkMode ? '#9CA3AF' : '#6B7280',
paddingVertical: 10,
},
input: {
height: 50,
borderWidth: 1,
borderColor: isDarkMode ? '#4B5563' : '#E5E7EB',
borderRadius: 8,
paddingHorizontal: 10,
backgroundColor: isDarkMode ? '#374151' : '#F9FAFB',
color: isDarkMode ? '#F9FAFB' : '#1F2937',
},
quotaContainer: {
marginVertical: 15,
padding: 15,
backgroundColor: isDarkMode ? '#1E3A8A' : '#EFF6FF',
borderRadius: 8,
},
quota: {
fontSize: 18,
fontWeight: 'bold',
color: isDarkMode ? '#93C5FD' : '#0055FF',
},
button: {
backgroundColor: isDarkMode ? '#3B82F6' : '#0055FF',
height: 50,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 15,
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
signOutButton: {
backgroundColor: isDarkMode ? '#DC2626' : '#EF4444',
marginTop: 10,
},
});
return (
<>
<Stack.Screen
options={{
title: 'Einstellungen',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView className="flex-1 px-4 py-4">
{session && session.user ? (
<View style={styles.card}>
<Text style={styles.sectionTitle}>Mein Profil</Text>
<View style={styles.formGroup}>
<Text style={styles.label}>E-Mail</Text>
<Text style={styles.value}>{session?.user?.email}</Text>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Vorname</Text>
<TextInput
style={styles.input}
value={firstName}
onChangeText={setFirstName}
placeholder="Vorname eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Nachname</Text>
<TextInput
style={styles.input}
value={lastName}
onChangeText={setLastName}
placeholder="Nachname eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
/>
</View>
{profile && profile.is_individual && (
<View style={styles.quotaContainer}>
<Text style={styles.label}>Verfügbare Kredite</Text>
<Text style={styles.quota}>
{profile.individual_quota - profile.individual_usage} / {profile.individual_quota}
</Text>
</View>
)}
<TouchableOpacity
style={styles.button}
onPress={updateProfile}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Wird aktualisiert...' : 'Profil aktualisieren'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.signOutButton]}
onPress={signOut}
disabled={loading}
>
<Text style={styles.buttonText}>Abmelden</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.card}>
<Text style={styles.sectionTitle}>Anmeldung erforderlich</Text>
<Text style={{ color: isDarkMode ? '#9CA3AF' : '#6B7280', marginBottom: 15 }}>
Bitte melden Sie sich an, um Ihr Profil zu verwalten.
</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.sectionTitle}>Erscheinungsbild</Text>
<View style={styles.themeButtonsContainer}>
<TouchableOpacity
style={[
styles.themeButton,
themeMode === 'system'
? styles.activeThemeButton
: styles.inactiveThemeButton,
]}
onPress={() => changeTheme('system')}
>
<FontAwesome5
name="laptop"
size={20}
color={themeMode === 'system' ? '#FFFFFF' : (isDarkMode ? '#F9FAFB' : '#1F2937')}
/>
<Text
style={[
styles.themeButtonText,
themeMode === 'system' && styles.activeThemeButtonText,
]}
>
System
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.themeButton,
themeMode === 'light'
? styles.activeThemeButton
: styles.inactiveThemeButton,
]}
onPress={() => changeTheme('light')}
>
<FontAwesome5
name="sun"
size={20}
color={themeMode === 'light' ? '#FFFFFF' : (isDarkMode ? '#F9FAFB' : '#1F2937')}
/>
<Text
style={[
styles.themeButtonText,
themeMode === 'light' && styles.activeThemeButtonText,
]}
>
Hell
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.themeButton,
themeMode === 'dark'
? styles.activeThemeButton
: styles.inactiveThemeButton,
]}
onPress={() => changeTheme('dark')}
>
<FontAwesome5
name="moon"
size={20}
color={themeMode === 'dark' ? '#FFFFFF' : (isDarkMode ? '#F9FAFB' : '#1F2937')}
/>
<Text
style={[
styles.themeButtonText,
themeMode === 'dark' && styles.activeThemeButtonText,
]}
>
Dunkel
</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Über</Text>
<Text style={{ color: isDarkMode ? '#9CA3AF' : '#6B7280' }}>
Manacore App Version 1.0.0
</Text>
</View>
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity, TextInput, Alert, ActivityIndicator, Modal, Pressable } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import TeamMembers from '../../../components/TeamMembers';
import { useTheme } from '../../../utils/themeContext';
import { supabase } from '../../../utils/supabase';
import { refreshTeamList } from '../../../components/TeamList';
export default function TeamDetails() {
const router = useRouter();
const { id: teamId, name: initialTeamName } = useLocalSearchParams<{ id: string, name: string }>();
const { isDarkMode } = useTheme();
const [teamName, setTeamName] = useState(initialTeamName || '');
const [isEditing, setIsEditing] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [loading, setLoading] = useState(false);
const [deletingTeam, setDeletingTeam] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
useEffect(() => {
if (initialTeamName) {
setTeamName(initialTeamName);
setNewTeamName(initialTeamName);
}
}, [initialTeamName]);
const navigateBack = () => {
router.push('/teams');
};
const startEditing = () => {
setIsEditing(true);
setNewTeamName(teamName);
};
const cancelEditing = () => {
setIsEditing(false);
};
const updateTeamName = async () => {
if (!newTeamName.trim()) {
Alert.alert('Fehler', 'Der Teamname darf nicht leer sein.');
return;
}
if (newTeamName.trim() === teamName) {
setIsEditing(false);
return;
}
setLoading(true);
try {
const { error } = await supabase
.from('teams')
.update({ name: newTeamName.trim() })
.eq('id', teamId);
if (error) throw error;
setTeamName(newTeamName.trim());
setIsEditing(false);
Alert.alert('Erfolg', 'Der Teamname wurde erfolgreich aktualisiert.');
} catch (error) {
console.error('Fehler beim Aktualisieren des Teamnamens:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Aktualisieren des Teamnamens aufgetreten.');
} finally {
setLoading(false);
}
};
const deleteTeam = () => {
console.log('Delete team button clicked, teamId:', teamId);
// Modal öffnen statt Alert anzeigen
setShowDeleteModal(true);
};
const confirmDelete = () => {
console.log('Löschen bestätigt für Team:', teamId);
setShowDeleteModal(false);
handleTeamDeletion();
};
const cancelDelete = () => {
console.log('Löschen abgebrochen');
setShowDeleteModal(false);
};
const handleTeamDeletion = async () => {
console.log('Starting deletion process for team:', teamId);
setDeletingTeam(true);
try {
// Zuerst alle Abhängigkeiten prüfen
console.log('Checking for dependencies...');
// 1. Prüfe auf credit_transactions
const { data: txData, error: txCheckError } = await supabase
.from('credit_transactions')
.select('id')
.eq('team_id', teamId);
console.log('Credit transactions:', txData);
// 2. Prüfe auf team_members
const { data: memberData, error: memberCheckError } = await supabase
.from('team_members')
.select('user_id')
.eq('team_id', teamId);
console.log('Team members:', memberData);
// 3. Prüfe auf user_roles
const { data: roleData, error: roleCheckError } = await supabase
.from('user_roles')
.select('id')
.eq('team_id', teamId);
console.log('User roles:', roleData);
// Jetzt versuchen wir, alle Abhängigkeiten zu löschen
// 1. Lösche credit_transactions
if (txData && txData.length > 0) {
console.log('Deleting credit transactions...');
const { error: txDeleteError } = await supabase
.from('credit_transactions')
.delete()
.eq('team_id', teamId);
if (txDeleteError) {
console.error('Error deleting credit transactions:', txDeleteError);
}
}
// 2. Lösche team_members
if (memberData && memberData.length > 0) {
console.log('Deleting team members...');
const { error: memberDeleteError } = await supabase
.from('team_members')
.delete()
.eq('team_id', teamId);
if (memberDeleteError) {
console.error('Error deleting team members:', memberDeleteError);
}
}
// 3. Lösche user_roles
if (roleData && roleData.length > 0) {
console.log('Deleting user roles...');
const { error: roleDeleteError } = await supabase
.from('user_roles')
.delete()
.eq('team_id', teamId);
if (roleDeleteError) {
console.error('Error deleting user roles:', roleDeleteError);
}
}
// 4. Schließlich das Team löschen
console.log('Deleting team...');
const { data, error } = await supabase
.from('teams')
.delete()
.eq('id', teamId);
console.log('Team deletion response:', { data, error });
if (error) {
throw error;
}
console.log('Team successfully deleted');
// Teamliste aktualisieren, wenn der Benutzer authentifiziert ist
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
// Teamliste aktualisieren
refreshTeamList(session.user.id, () => {
console.log('Team list refreshed after deletion');
});
}
// Erfolgsmeldung anzeigen und zurück zur Teamliste navigieren
Alert.alert(
'Erfolg',
'Das Team wurde erfolgreich gelöscht.',
[{
text: 'OK',
onPress: () => {
console.log('Navigating back to teams list');
// Zurück zur Teamliste navigieren
router.replace('/teams');
}
}]
);
// Auch ohne Klick auf OK zurück zur Teamliste navigieren (nach kurzer Verzögerung)
setTimeout(() => {
router.replace('/teams');
}, 1000);
} catch (error: any) {
console.error('Fehler beim Löschen des Teams:', error);
// Detaillierte Fehlermeldung anzeigen
Alert.alert(
'Fehler',
`Es ist ein Fehler beim Löschen des Teams aufgetreten: ${error?.message || JSON.stringify(error)}`,
[{ text: 'OK' }]
);
} finally {
setDeletingTeam(false);
}
};
return (
<>
<Stack.Screen
options={{
title: teamName || 'Team-Details',
headerLargeTitle: true,
}}
/>
{/* Lösch-Bestätigungsmodal */}
<Modal
animationType="fade"
transparent={true}
visible={showDeleteModal}
onRequestClose={cancelDelete}
>
<View className="flex-1 justify-center items-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View className={`m-5 p-6 rounded-xl shadow-lg ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`} style={{ width: '80%' }}>
<View className="items-center mb-4">
<FontAwesome5 name="exclamation-triangle" size={40} color="#EF4444" />
</View>
<Text className={`text-xl font-bold mb-2 text-center ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
Team löschen
</Text>
<Text className={`text-base mb-6 text-center ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Möchtest du das Team "{teamName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</Text>
<View className="flex-row justify-between">
<TouchableOpacity
className={`flex-1 py-3 rounded-lg mr-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`}
onPress={cancelDelete}
>
<Text className={`text-center font-medium ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-1 py-3 rounded-lg ml-2 bg-red-600"
onPress={confirmDelete}
disabled={deletingTeam}
>
{deletingTeam ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-center font-medium text-white">Löschen</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
<Container>
<ScrollView className="flex-1">
<View className="flex-row justify-between mx-2.5 my-2.5">
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg border ${isDarkMode ? 'border-blue-500' : 'border-blue-600'}`}
onPress={navigateBack}
>
<FontAwesome5 name="arrow-left" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2" />
<Text className={`${isDarkMode ? 'text-blue-400' : 'text-blue-600'} font-semibold text-sm`}>Zurück zu meinen Teams</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg ${deletingTeam ? 'bg-gray-500' : 'bg-red-600'}`}
onPress={deleteTeam} // Öffnet jetzt den Modal-Dialog
disabled={deletingTeam}
activeOpacity={0.7}
>
{deletingTeam ? (
<ActivityIndicator size="small" color="white" />
) : (
<>
<FontAwesome5 name="trash-alt" size={16} color="white" className="mr-2" />
<Text className="text-white font-semibold text-sm">Team löschen</Text>
</>
)}
</TouchableOpacity>
</View>
<TeamMembers teamId={teamId} />
</ScrollView>
</Container>
</>
);
}

View file

@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { Stack, useRouter } from 'expo-router';
import { ScrollView, Text, View, TouchableOpacity } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { Container } from '~/components/Container';
import TeamList from '../../../components/TeamList';
import CreateTeam from '../../../components/CreateTeam';
import { useTheme } from '../../../utils/themeContext';
export default function Teams() {
const [showCreateTeam, setShowCreateTeam] = useState(false);
const { isDarkMode } = useTheme();
const router = useRouter();
const handleTeamCreated = (teamId: string, teamName: string) => {
console.log('Team erstellt, navigiere zur Detailseite:', teamId, teamName);
// Zuerst zurück zur Teamliste wechseln
setShowCreateTeam(false);
// Dann zur Detailseite des neuen Teams navigieren
// Verzögerung erhöht für stabilere Navigation
setTimeout(() => {
router.push({
pathname: '/teams/[id]',
params: { id: teamId, name: teamName }
});
}, 300); // Verzögerung erhöht für bessere Stabilität
};
return (
<>
<Stack.Screen
options={{
title: 'Teams',
headerLargeTitle: true,
}}
/>
<Container>
<ScrollView className="flex-1">
{!showCreateTeam ? (
<>
<View className="flex-row justify-between items-center mx-2.5 my-2.5">
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Meine Teams</Text>
<TouchableOpacity
className={`${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'} flex-row items-center py-2 px-4 rounded-lg shadow`}
onPress={() => setShowCreateTeam(true)}
>
<FontAwesome5 name="plus" size={14} color="white" className="mr-2" />
<Text className="text-white font-semibold text-sm">Neues Team</Text>
</TouchableOpacity>
</View>
<TeamList hideTitle={true} />
</>
) : (
<>
<View className="flex-row justify-end mx-2.5 my-2.5">
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg border ${isDarkMode ? 'border-blue-500' : 'border-blue-600'}`}
onPress={() => setShowCreateTeam(false)}
>
<FontAwesome5 name="arrow-left" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2" />
<Text className={`${isDarkMode ? 'text-blue-400' : 'text-blue-600'} font-semibold text-sm`}>Zurück zu meinen Teams</Text>
</TouchableOpacity>
</View>
<CreateTeam onTeamCreated={handleTeamCreated} />
</>
)}
</ScrollView>
</Container>
</>
);
}
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich

View file

@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

@ -0,0 +1,24 @@
import { Link, Stack } from 'expo-router';
import { Text } from 'react-native';
import { Container } from '~/components/Container';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<Container>
<Text className={styles.title}>This screen doesn't exist.</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</Container>
</>
);
}
const styles = {
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -0,0 +1,78 @@
import '../global.css';
import React, { useEffect, useState } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider } from '~/utils/themeContext';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(drawer)',
};
// Auth Provider Komponente für die Authentifizierungsprüfung
function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
const segments = useSegments();
const router = useRouter();
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setIsLoading(false);
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setIsLoading(false);
});
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
if (isLoading) return;
// Prüfe, ob der Benutzer auf der Anmeldeseite oder Passwort-Reset-Seite ist
const isLoginScreen = segments[0] === 'login';
const isAuthScreen = segments[0] === 'auth'; // For reset-password and other auth routes
const isPublicScreen = isLoginScreen || isAuthScreen;
if (!session && !isPublicScreen) {
// Wenn der Benutzer nicht angemeldet ist und nicht auf einer öffentlichen Seite ist,
// leite ihn zur Anmeldeseite um
router.replace('/login');
} else if (session && isLoginScreen) {
// Wenn der Benutzer angemeldet ist und auf der Anmeldeseite ist,
// leite ihn zur Hauptseite um
router.replace('/');
}
}, [session, segments, isLoading]);
// Zeige nichts während des Ladens
if (isLoading) return null;
return <>{children}</>;
}
export default function RootLayout() {
return (
<ThemeProvider>
<AuthProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ title: 'Modal', presentation: 'modal' }} />
<Stack.Screen name="login" options={{ headerShown: true }} />
<Stack.Screen name="auth" options={{ headerShown: false }} />
</Stack>
</GestureHandlerRootView>
</AuthProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,9 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack>
<Stack.Screen name="reset-password" options={{ headerShown: false }} />
</Stack>
);
}

View file

@ -0,0 +1,292 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator, Platform } from 'react-native';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { supabase } from '../../utils/supabase';
import { useTheme } from '../../utils/themeContext';
export default function ResetPasswordScreen() {
const { isDarkMode } = useTheme();
const router = useRouter();
const params = useLocalSearchParams();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(true);
const [isValidToken, setIsValidToken] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
useEffect(() => {
// Only allow on web platform
if (Platform.OS !== 'web') {
Alert.alert('Nicht verfügbar', 'Diese Funktion ist nur in der Web-Version verfügbar');
setTimeout(() => router.replace('/login'), 100);
return;
}
verifyToken();
}, []);
const verifyToken = async () => {
try {
setVerifying(true);
console.log('URL params:', params);
// Check if access token is in the URL hash (Supabase puts it there)
if (typeof window !== 'undefined' && window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const access_token = hashParams.get('access_token');
const type = hashParams.get('type');
console.log('Hash params:', { access_token, type });
if (access_token && type === 'recovery') {
setAccessToken(access_token);
setIsValidToken(true);
setVerifying(false);
return;
}
}
// Check if access token is in regular params (sometimes it's there)
const paramAccessToken = params.access_token as string;
const paramType = params.type as string;
if (paramAccessToken && paramType === 'recovery') {
setAccessToken(paramAccessToken);
setIsValidToken(true);
setVerifying(false);
return;
}
// If no access token found, check for error parameters
const error = params.error as string;
const error_description = params.error_description as string;
if (error) {
console.error('Auth error:', error, error_description);
Alert.alert('Fehler', error_description || 'Ungültiger oder abgelaufener Link');
setTimeout(() => router.replace('/login'), 100);
return;
}
// If we get here, no valid token was found
Alert.alert('Fehler', 'Ungültiger Wiederherstellungslink');
setTimeout(() => router.replace('/login'), 100);
} catch (error) {
console.error('Token verification error:', error);
Alert.alert('Fehler', 'Ungültiger oder abgelaufener Link');
setTimeout(() => router.replace('/login'), 100);
} finally {
setVerifying(false);
}
};
const handleResetPassword = async () => {
if (password !== confirmPassword) {
Alert.alert('Fehler', 'Passwörter stimmen nicht überein');
return;
}
if (password.length < 6) {
Alert.alert('Fehler', 'Passwort muss mindestens 6 Zeichen lang sein');
return;
}
if (!accessToken) {
Alert.alert('Fehler', 'Kein gültiger Token gefunden');
return;
}
setLoading(true);
try {
const apiUrl = process.env.EXPO_PUBLIC_API_URL || 'https://mana-core-middleware-111768794939.europe-west3.run.app';
const endpoint = `${apiUrl}/auth/update-password`;
console.log('Calling update password endpoint:', endpoint);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: accessToken,
newPassword: password
}),
});
const data = await response.json();
console.log('Update password response:', data);
if (!response.ok) {
throw new Error(data.message || 'Passwort konnte nicht zurückgesetzt werden');
}
Alert.alert(
'Erfolg',
'Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.',
[
{
text: 'Zur Anmeldung',
onPress: () => router.replace('/login'),
},
]
);
} catch (error: any) {
console.error('Update password error:', error);
Alert.alert('Fehler', error.message || 'Passwort konnte nicht zurückgesetzt werden');
} finally {
setLoading(false);
}
};
if (verifying) {
return (
<View style={[styles.container, styles.centered, { backgroundColor: isDarkMode ? '#121212' : '#F5F5F5' }]}>
<Stack.Screen options={{ title: 'Passwort zurücksetzen' }} />
<ActivityIndicator size="large" color={isDarkMode ? '#BB86FC' : '#6366F1'} />
<Text style={[styles.loadingText, { color: isDarkMode ? '#F9FAFB' : '#1F2937' }]}>
Link wird überprüft...
</Text>
</View>
);
}
if (!isValidToken) {
return null;
}
return (
<>
<Stack.Screen
options={{
title: 'Passwort zurücksetzen',
headerStyle: {
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
headerTintColor: isDarkMode ? '#F9FAFB' : '#1F2937',
}}
/>
<View style={[styles.container, { backgroundColor: isDarkMode ? '#121212' : '#F5F5F5' }]}>
<View style={[styles.formContainer, { backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF' }]}>
<Text style={[styles.title, { color: isDarkMode ? '#F9FAFB' : '#1F2937' }]}>
Neues Passwort festlegen
</Text>
<Text style={[styles.subtitle, { color: isDarkMode ? '#9CA3AF' : '#6B7280' }]}>
Bitte geben Sie Ihr neues Passwort ein
</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: isDarkMode ? '#2D2D2D' : '#F9FAFB',
color: isDarkMode ? '#F9FAFB' : '#1F2937',
borderColor: isDarkMode ? '#4B5563' : '#E5E7EB',
},
]}
placeholder="Neues Passwort"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
/>
<TextInput
style={[
styles.input,
{
backgroundColor: isDarkMode ? '#2D2D2D' : '#F9FAFB',
color: isDarkMode ? '#F9FAFB' : '#1F2937',
borderColor: isDarkMode ? '#4B5563' : '#E5E7EB',
},
]}
placeholder="Passwort bestätigen"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
autoCapitalize="none"
/>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? '#BB86FC' : '#6366F1' },
loading && styles.buttonDisabled,
]}
onPress={handleResetPassword}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>Passwort zurücksetzen</Text>
)}
</TouchableOpacity>
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
formContainer: {
padding: 24,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
marginBottom: 24,
},
input: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginBottom: 16,
fontSize: 16,
},
button: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -0,0 +1,34 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Stack } from 'expo-router';
import Auth from '../components/Auth';
import { useTheme } from '../utils/themeContext';
export default function LoginScreen() {
const { isDarkMode } = useTheme();
return (
<>
<Stack.Screen options={{
title: 'Anmelden',
headerStyle: {
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
headerTintColor: isDarkMode ? '#F9FAFB' : '#1F2937',
}} />
<View style={[
styles.container,
{ backgroundColor: isDarkMode ? '#121212' : '#F5F5F5' }
]}>
<Auth />
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
});

View file

@ -0,0 +1,13 @@
import { StatusBar } from 'expo-status-bar';
import { Platform } from 'react-native';
import { ScreenContent } from '~/components/ScreenContent';
export default function Modal() {
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal" />
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
plugins.push('react-native-reanimated/plugin');
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View file

@ -0,0 +1,40 @@
{
"cesVersion": "2.14.1",
"projectName": "manacore",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "drawer + tabs"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "supabase",
"type": "authentication"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "npm",
"eas": true,
"publish": false
},
"packageManager": {
"type": "npm",
"version": "10.7.0"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.1.0"
}
}

View file

@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
interface Profile {
id: string;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
is_individual: boolean;
individual_quota: number;
individual_usage: number;
}
export default function Account({ session }: { session: Session }) {
const [loading, setLoading] = useState(true);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
if (session) getProfile();
}, [session]);
async function getProfile() {
try {
setLoading(true);
const { user } = session;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
if (error) {
throw error;
}
if (data) {
setProfile(data);
setFirstName(data.first_name || '');
setLastName(data.last_name || '');
}
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Laden des Profils', error.message);
}
} finally {
setLoading(false);
}
}
async function updateProfile() {
try {
setLoading(true);
const { user } = session;
// Prüfen, ob das Profil bereits existiert
if (!profile) {
// Erstelle ein neues Profil, wenn es noch nicht existiert
const { error: insertError } = await supabase
.from('profiles')
.insert([
{
id: user.id,
first_name: firstName,
last_name: lastName,
is_individual: true, // Standardmäßig als Einzelnutzer
individual_quota: 0,
individual_usage: 0,
},
]);
if (insertError) throw insertError;
} else {
// Aktualisiere das bestehende Profil
const { error: updateError } = await supabase
.from('profiles')
.update({
first_name: firstName,
last_name: lastName,
updated_at: new Date(),
})
.eq('id', user.id);
if (updateError) throw updateError;
}
Alert.alert('Erfolg', 'Profil erfolgreich aktualisiert!');
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Aktualisieren des Profils', error.message);
}
} finally {
setLoading(false);
}
}
async function signOut() {
try {
setLoading(true);
const { error } = await supabase.auth.signOut();
if (error) throw error;
} catch (error) {
if (error instanceof Error) {
Alert.alert('Fehler beim Abmelden', error.message);
}
} finally {
setLoading(false);
}
}
return (
<View style={styles.container}>
<View style={styles.profileContainer}>
<Text style={styles.header}>Mein Profil</Text>
<View style={styles.formGroup}>
<Text style={styles.label}>E-Mail</Text>
<Text style={styles.value}>{session?.user?.email}</Text>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Vorname</Text>
<TextInput
style={styles.input}
value={firstName}
onChangeText={setFirstName}
placeholder="Vorname eingeben"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Nachname</Text>
<TextInput
style={styles.input}
value={lastName}
onChangeText={setLastName}
placeholder="Nachname eingeben"
/>
</View>
{profile && profile.is_individual && (
<View style={styles.quotaContainer}>
<Text style={styles.label}>Verfügbare Kredite</Text>
<Text style={styles.quota}>
{profile.individual_quota - profile.individual_usage} / {profile.individual_quota}
</Text>
</View>
)}
<TouchableOpacity
style={styles.button}
onPress={updateProfile}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Wird aktualisiert...' : 'Profil aktualisieren'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.signOutButton]}
onPress={signOut}
disabled={loading}
>
<Text style={styles.buttonText}>Abmelden</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
profileContainer: {
backgroundColor: 'white',
padding: 20,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
header: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
formGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 5,
fontWeight: '500',
},
value: {
fontSize: 16,
color: '#666',
paddingVertical: 10,
},
input: {
height: 50,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 5,
paddingHorizontal: 10,
backgroundColor: '#f9f9f9',
},
quotaContainer: {
marginVertical: 15,
padding: 15,
backgroundColor: '#f0f8ff',
borderRadius: 5,
},
quota: {
fontSize: 18,
fontWeight: 'bold',
color: '#0055FF',
},
button: {
backgroundColor: '#0055FF',
height: 50,
borderRadius: 5,
justifyContent: 'center',
alignItems: 'center',
marginTop: 15,
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
signOutButton: {
backgroundColor: '#ff3b30',
marginTop: 10,
},
});

View file

@ -0,0 +1,329 @@
import React, { useState } from 'react';
import { Alert, StyleSheet, View, TextInput, TouchableOpacity, Text, Image, Platform } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { supabase } from '../utils/supabase';
import { useTheme, useThemeColors, lightColors, darkColors } from '../utils/themeContext';
export default function Auth() {
const { isDarkMode } = useTheme();
const themeColors = useThemeColors();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [isSignUp, setIsSignUp] = useState(false);
const [isResetPassword, setIsResetPassword] = useState(false);
async function signInWithEmail() {
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
Alert.alert('Fehler bei der Anmeldung', error.message);
}
setLoading(false);
}
async function signUpWithEmail() {
setLoading(true);
const { error } = await supabase.auth.signUp({
email,
password,
});
if (error) {
Alert.alert('Fehler bei der Registrierung', error.message);
} else {
Alert.alert(
'Registrierung erfolgreich',
'Bitte überprüfen Sie Ihre E-Mail für den Bestätigungslink.'
);
}
setLoading(false);
}
async function resetPassword() {
console.log('Reset password called with email:', email);
if (!email) {
Alert.alert('Fehler', 'Bitte geben Sie Ihre E-Mail-Adresse ein');
return;
}
setLoading(true);
try {
const apiUrl = process.env.EXPO_PUBLIC_API_URL || 'https://mana-core-middleware-111768794939.europe-west3.run.app';
const endpoint = `${apiUrl}/auth/reset-password`;
console.log('Calling API endpoint:', endpoint);
console.log('Request body:', { email });
// Call your backend endpoint for password reset
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
console.log('Response status:', response.status);
const data = await response.json();
console.log('Response data:', data);
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Zurücksetzen des Passworts');
}
Alert.alert(
'E-Mail gesendet',
'Bitte überprüfen Sie Ihre E-Mail für den Link zum Zurücksetzen des Passworts.'
);
setIsResetPassword(false);
} catch (error: any) {
console.error('Reset password error:', error);
Alert.alert('Fehler', error.message || 'Netzwerkfehler. Bitte versuchen Sie es später erneut.');
}
setLoading(false);
}
return (
<View style={[styles.container, { backgroundColor: isDarkMode ? '#121212' : '#F5F5F5' }]}>
<View style={[styles.formContainer, { backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF', borderColor: isDarkMode ? '#374151' : '#E5E7EB' }]}>
<View style={styles.logoContainer}>
<FontAwesome5 name="fire" size={40} color="#F59E0B" style={styles.logoIcon} />
<Text style={[styles.logoText, { color: isDarkMode ? '#F9FAFB' : '#1F2937' }]}>ManaCore</Text>
</View>
<Text style={[styles.header, { color: isDarkMode ? '#F9FAFB' : '#1F2937' }]}>
{isResetPassword ? 'Passwort zurücksetzen' : isSignUp ? 'Registrieren' : 'Anmelden'}
</Text>
<Text style={[styles.subHeader, { color: isDarkMode ? '#9CA3AF' : '#6B7280' }]}>
{isResetPassword
? 'Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen'
: isSignUp
? 'Erstellen Sie ein neues Konto, um Mana zu teilen und zu verwalten'
: 'Melden Sie sich an, um Ihre Mana zu verwalten'}
</Text>
<View style={styles.inputContainer}>
<FontAwesome5 name="envelope" size={16} color={isDarkMode ? '#9CA3AF' : '#6B7280'} style={styles.inputIcon} />
<TextInput
style={[styles.input, {
backgroundColor: isDarkMode ? '#374151' : '#F9FAFB',
borderColor: isDarkMode ? '#4B5563' : '#E5E7EB',
color: isDarkMode ? '#F9FAFB' : '#1F2937'
}]}
placeholder="E-Mail"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
{!isResetPassword && (
<View style={styles.inputContainer}>
<FontAwesome5 name="lock" size={16} color={isDarkMode ? '#9CA3AF' : '#6B7280'} style={styles.inputIcon} />
<TextInput
style={[styles.input, {
backgroundColor: isDarkMode ? '#374151' : '#F9FAFB',
borderColor: isDarkMode ? '#4B5563' : '#E5E7EB',
color: isDarkMode ? '#F9FAFB' : '#1F2937'
}]}
placeholder="Passwort"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
/>
</View>
)}
<TouchableOpacity
style={[styles.button, {
backgroundColor: themeColors.primary,
opacity: loading ? 0.7 : 1
}]}
onPress={() => {
if (isResetPassword) {
resetPassword();
} else if (isSignUp) {
signUpWithEmail();
} else {
signInWithEmail();
}
}}
disabled={loading}
>
{loading ? (
<View style={styles.loadingContainer}>
<Text style={styles.buttonText}>Lädt</Text>
<View style={styles.dotsContainer}>
<Text style={styles.dots}>.</Text>
<Text style={styles.dots}>.</Text>
<Text style={styles.dots}>.</Text>
</View>
</View>
) : (
<Text style={styles.buttonText}>
{isResetPassword ? 'Link senden' : isSignUp ? 'Registrieren' : 'Anmelden'}
</Text>
)}
</TouchableOpacity>
{Platform.OS === 'web' && !isSignUp && !isResetPassword && (
<TouchableOpacity
style={styles.forgotPasswordButton}
onPress={() => setIsResetPassword(true)}
>
<Text style={[styles.forgotPasswordText, { color: themeColors.primary }]}>
Passwort vergessen?
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.switchButton}
onPress={() => {
if (isResetPassword) {
setIsResetPassword(false);
} else {
setIsSignUp(!isSignUp);
}
}}
>
<Text style={[styles.switchText, { color: themeColors.primary }]}>
{isResetPassword
? 'Zurück zur Anmeldung'
: isSignUp
? 'Bereits ein Konto? Anmelden'
: 'Kein Konto? Registrieren'}
</Text>
</TouchableOpacity>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: isDarkMode ? '#9CA3AF' : '#6B7280' }]}>
© {new Date().getFullYear()} ManaCore. Alle Rechte vorbehalten.
</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
formContainer: {
padding: 24,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderWidth: 1,
maxWidth: 500,
width: '100%',
alignSelf: 'center',
},
logoContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
logoIcon: {
marginRight: 12,
},
logoText: {
fontSize: 28,
fontWeight: 'bold',
},
header: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
textAlign: 'center',
},
subHeader: {
fontSize: 16,
textAlign: 'center',
marginBottom: 24,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
inputIcon: {
marginRight: 12,
},
input: {
flex: 1,
height: 50,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
},
button: {
height: 54,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 24,
},
buttonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
loadingContainer: {
flexDirection: 'row',
alignItems: 'center',
},
dotsContainer: {
flexDirection: 'row',
marginLeft: 4,
},
dots: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
marginLeft: 2,
},
switchButton: {
marginTop: 24,
alignItems: 'center',
},
switchText: {
fontSize: 16,
},
forgotPasswordButton: {
marginTop: 16,
alignItems: 'center',
},
forgotPasswordText: {
fontSize: 14,
},
footer: {
marginTop: 40,
alignItems: 'center',
},
footerText: {
fontSize: 12,
},
});

View file

@ -0,0 +1,22 @@
import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View file

@ -0,0 +1,12 @@
import { SafeAreaView, View } from 'react-native';
import { useTheme } from '~/utils/themeContext';
export const Container = ({ children }: { children: React.ReactNode }) => {
const { isDarkMode } = useTheme();
return (
<SafeAreaView className={`flex flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-white'}`}>
<View className="flex flex-1 m-6">{children}</View>
</SafeAreaView>
);
};

View file

@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert, ActivityIndicator, ScrollView } from 'react-native';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useTheme } from '../utils/themeContext';
interface UserRole {
role_id: string;
roles?: {
name: string;
};
}
interface CreateOrganizationProps {
onOrgCreated?: (orgId: string, orgName: string) => void;
}
export default function CreateOrganization({ onOrgCreated }: CreateOrganizationProps) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [initialCredits, setInitialCredits] = useState('');
const [userHasPermission, setUserHasPermission] = useState(false);
const [checkingPermission, setCheckingPermission] = useState(true);
const { isDarkMode } = useTheme();
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
checkUserPermission(session.user.id);
} else {
setCheckingPermission(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
checkUserPermission(session.user.id);
} else {
setCheckingPermission(false);
}
});
return () => subscription.unsubscribe();
}, []);
async function checkUserPermission(userId: string) {
try {
setCheckingPermission(true);
// Prüfe, ob der Benutzer ein System-Administrator ist
const { data: userRoles, error: userRolesError } = await supabase
.from('user_roles')
.select('role_id, roles(name)')
.eq('user_id', userId) as { data: UserRole[] | null, error: any };
if (userRolesError) throw userRolesError;
if (userRoles && userRoles.length > 0) {
// Prüfe, ob der Benutzer die Rolle "system_admin" hat
const isSystemAdmin = userRoles.some(role => {
const roleName = role.roles?.name;
return roleName === 'system_admin';
});
setUserHasPermission(isSystemAdmin);
} else {
setUserHasPermission(false);
}
} catch (error) {
console.error('Fehler beim Prüfen der Benutzerberechtigungen:', error);
setUserHasPermission(false);
} finally {
setCheckingPermission(false);
}
}
async function createOrganization() {
if (!session) {
Alert.alert('Fehler', 'Sie müssen angemeldet sein, um eine Organisation zu erstellen.');
return;
}
if (!userHasPermission) {
Alert.alert('Fehler', 'Sie haben keine Berechtigung, Organisationen zu erstellen.');
return;
}
if (!organizationName.trim()) {
Alert.alert('Fehler', 'Bitte geben Sie einen Organisationsnamen ein.');
return;
}
const credits = parseInt(initialCredits);
if (isNaN(credits) || credits < 0) {
Alert.alert('Fehler', 'Bitte geben Sie eine gültige Anzahl an Krediten ein.');
return;
}
try {
setLoading(true);
// 1. Erstelle die Organisation
const { data: organization, error: orgError } = await supabase
.from('organizations')
.insert([
{
name: organizationName.trim(),
total_credits: credits,
used_credits: 0
}
])
.select()
.single();
if (orgError) throw orgError;
// 2. Hole die org_admin Rolle
const { data: adminRole, error: roleError } = await supabase
.from('roles')
.select('id')
.eq('name', 'org_admin')
.single();
if (roleError) throw roleError;
// 3. Füge den aktuellen Benutzer als Organisations-Administrator hinzu
const { error: userRoleError } = await supabase
.from('user_roles')
.insert([
{
user_id: session.user.id,
role_id: adminRole.id,
organization_id: organization.id
}
]);
if (userRoleError) throw userRoleError;
// Erfolgsbenachrichtigung anzeigen und direkt navigieren
Alert.alert(
'Erfolg',
`Die Organisation "${organizationName}" wurde erfolgreich erstellt.`
);
// Direkt zur Organisationsdetailseite navigieren, ohne auf OK zu warten
if (onOrgCreated) {
console.log('Navigiere zur neuen Organisationsseite:', organization.id, organization.name);
onOrgCreated(organization.id, organization.name);
}
// Formular zurücksetzen
setOrganizationName('');
setInitialCredits('');
} catch (error) {
console.error('Fehler beim Erstellen der Organisation:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Erstellen der Organisation aufgetreten.');
} finally {
setLoading(false);
}
}
if (!session) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Bitte melden Sie sich an, um Organisationen zu erstellen.
</Text>
</View>
);
}
if (checkingPermission) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<ActivityIndicator size="large" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Prüfe Berechtigungen...</Text>
</View>
);
}
if (!userHasPermission) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Sie haben keine Berechtigung, Organisationen zu erstellen. Bitte kontaktieren Sie einen Administrator.
</Text>
</View>
);
}
return (
<ScrollView className="flex-1">
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-2xl font-bold mb-5 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Neue Organisation erstellen</Text>
<View className="mb-4">
<Text className={`text-base font-medium mb-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>Organisationsname</Text>
<TextInput
className={`h-12 border ${isDarkMode ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-300 bg-gray-50 text-gray-800'} rounded-lg px-4 text-base`}
value={organizationName}
onChangeText={setOrganizationName}
placeholder="Name der Organisation eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
/>
</View>
<View className="mb-4">
<Text className={`text-base font-medium mb-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>Anfängliche Kredite</Text>
<TextInput
className={`h-12 border ${isDarkMode ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-300 bg-gray-50 text-gray-800'} rounded-lg px-4 text-base`}
value={initialCredits}
onChangeText={setInitialCredits}
placeholder="Anzahl der Kredite eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
keyboardType="number-pad"
/>
<Text className={`text-sm mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Dies ist die Gesamtanzahl der Kredite, die dieser Organisation zur Verfügung stehen werden.
</Text>
</View>
<TouchableOpacity
className={`${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'} py-3 px-4 rounded-lg ${loading ? 'opacity-70' : ''}`}
onPress={createOrganization}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-white font-semibold text-center text-base">Organisation erstellen</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
}
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich

View file

@ -0,0 +1,326 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert, ActivityIndicator, ScrollView } from 'react-native';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useTheme } from '../utils/themeContext';
import { useRouter } from 'expo-router';
interface Organization {
id: string;
name: string;
}
interface UserRole {
organization_id: string;
roles?: {
name: string;
};
}
interface CreateTeamProps {
onTeamCreated?: (teamId: string, teamName: string) => void;
}
export default function CreateTeam({ onTeamCreated }: CreateTeamProps) {
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(false);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [teamName, setTeamName] = useState('');
const [allocatedCredits, setAllocatedCredits] = useState('');
const [fetchingOrgs, setFetchingOrgs] = useState(true);
const { isDarkMode } = useTheme();
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchUserOrganizations(session.user.id);
} else {
setFetchingOrgs(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchUserOrganizations(session.user.id);
} else {
setFetchingOrgs(false);
}
});
return () => subscription.unsubscribe();
}, []);
async function fetchUserOrganizations(userId: string) {
try {
setFetchingOrgs(true);
// Suche nach Organisationen, in denen der Benutzer eine Rolle hat
const { data: userRoles, error: userRolesError } = await supabase
.from('user_roles')
.select('organization_id, roles(name)')
.eq('user_id', userId)
.not('organization_id', 'is', null) as { data: UserRole[] | null, error: any };
if (userRolesError) throw userRolesError;
if (userRoles && userRoles.length > 0) {
// Extrahiere die Organisations-IDs
const orgIds = userRoles
.filter(role => {
// Prüfe, ob der Benutzer ein Administrator oder Manager ist
const roleName = role.roles?.name;
return roleName === 'system_admin' ||
roleName === 'org_admin' ||
roleName === 'team_admin';
})
.map(role => role.organization_id);
if (orgIds.length > 0) {
// Hole die Details der Organisationen
const { data: orgs, error: orgsError } = await supabase
.from('organizations')
.select('id, name')
.in('id', orgIds);
if (orgsError) throw orgsError;
if (orgs && orgs.length > 0) {
setOrganizations(orgs);
// Setze die erste Organisation als Standard
setSelectedOrgId(orgs[0].id);
}
}
}
} catch (error) {
console.error('Fehler beim Abrufen der Organisationen:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden der Organisationen aufgetreten.');
} finally {
setFetchingOrgs(false);
}
}
async function createTeam() {
if (!session) {
Alert.alert('Fehler', 'Sie müssen angemeldet sein, um ein Team zu erstellen.');
return;
}
if (!selectedOrgId) {
Alert.alert('Fehler', 'Bitte wählen Sie eine Organisation aus.');
return;
}
if (!teamName.trim()) {
Alert.alert('Fehler', 'Bitte geben Sie einen Teamnamen ein.');
return;
}
const credits = parseInt(allocatedCredits);
if (isNaN(credits) || credits < 0) {
Alert.alert('Fehler', 'Bitte geben Sie eine gültige Anzahl an Krediten ein.');
return;
}
try {
setLoading(true);
// 1. Prüfe, ob die Organisation genügend Kredite hat
const { data: org, error: orgError } = await supabase
.from('organizations')
.select('total_credits, used_credits')
.eq('id', selectedOrgId)
.single();
if (orgError) throw orgError;
const availableCredits = org.total_credits - org.used_credits;
if (credits > availableCredits) {
Alert.alert('Fehler', `Die Organisation hat nicht genügend Kredite. Verfügbar: ${availableCredits}`);
return;
}
// 2. Erstelle das Team
const { data: team, error: teamError } = await supabase
.from('teams')
.insert([
{
organization_id: selectedOrgId,
name: teamName.trim(),
allocated_credits: credits
}
])
.select()
.single();
if (teamError) throw teamError;
// 3. Aktualisiere die verwendeten Kredite der Organisation
const { error: updateOrgError } = await supabase
.from('organizations')
.update({
used_credits: org.used_credits + credits
})
.eq('id', selectedOrgId);
if (updateOrgError) throw updateOrgError;
// 4. Füge den aktuellen Benutzer als Team-Administrator hinzu
// Zuerst hole die team_admin Rolle
const { data: adminRole, error: roleError } = await supabase
.from('roles')
.select('id')
.eq('name', 'team_admin')
.single();
if (roleError) throw roleError;
// Füge die Benutzerrolle hinzu
const { error: userRoleError } = await supabase
.from('user_roles')
.insert([
{
user_id: session.user.id,
role_id: adminRole.id,
team_id: team.id,
organization_id: selectedOrgId
}
]);
if (userRoleError) throw userRoleError;
// 5. Füge den Benutzer als Teammitglied hinzu
const { error: teamMemberError } = await supabase
.from('team_members')
.insert([
{
team_id: team.id,
user_id: session.user.id,
allocated_credits: 0, // Standardmäßig keine Kredite zugewiesen
used_credits: 0
}
]);
if (teamMemberError) throw teamMemberError;
// Erfolgsbenachrichtigung anzeigen und direkt navigieren
Alert.alert(
'Erfolg',
`Das Team "${teamName}" wurde erfolgreich erstellt.`
);
// Direkt zur Teamdetailseite navigieren, ohne auf OK zu warten
if (onTeamCreated) {
console.log('Navigiere zur neuen Teamseite:', team.id, team.name);
onTeamCreated(team.id, team.name);
}
// Formular zurücksetzen
setTeamName('');
setAllocatedCredits('');
} catch (error) {
console.error('Fehler beim Erstellen des Teams:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Erstellen des Teams aufgetreten.');
} finally {
setLoading(false);
}
}
if (!session) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Bitte melden Sie sich an, um Teams zu erstellen.
</Text>
</View>
);
}
if (fetchingOrgs) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<ActivityIndicator size="large" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Lade Organisationen...</Text>
</View>
);
}
if (organizations.length === 0) {
return (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Sie haben keine Berechtigung, Teams zu erstellen, oder Sie gehören keiner Organisation an.
</Text>
</View>
);
}
return (
<ScrollView className="flex-1">
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-5 m-2.5 shadow`}>
<Text className={`text-2xl font-bold mb-5 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Neues Team erstellen</Text>
<View className="mb-4">
<Text className={`text-base font-medium mb-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>Organisation</Text>
<View className="flex-row flex-wrap mb-2.5">
{organizations.map((org) => (
<TouchableOpacity
key={org.id}
className={`${selectedOrgId === org.id ? (isDarkMode ? 'bg-blue-800' : 'bg-blue-600') : (isDarkMode ? 'bg-gray-700' : 'bg-gray-100')} py-2.5 px-4 rounded-lg mr-2.5 mb-2.5`}
onPress={() => setSelectedOrgId(org.id)}
>
<Text
className={`${selectedOrgId === org.id ? 'text-white' : (isDarkMode ? 'text-gray-300' : 'text-gray-700')} font-medium`}
>
{org.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View className="mb-4">
<Text className={`text-base font-medium mb-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>Teamname</Text>
<TextInput
className={`h-12 border ${isDarkMode ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-300 bg-gray-50 text-gray-800'} rounded-lg px-4 text-base`}
value={teamName}
onChangeText={setTeamName}
placeholder="Name des Teams eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
/>
</View>
<View className="mb-4">
<Text className={`text-base font-medium mb-2 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>Zugewiesene Kredite</Text>
<TextInput
className={`h-12 border ${isDarkMode ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-300 bg-gray-50 text-gray-800'} rounded-lg px-4 text-base`}
value={allocatedCredits}
onChangeText={setAllocatedCredits}
placeholder="Anzahl der Kredite eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
keyboardType="number-pad"
/>
</View>
<TouchableOpacity
className={`h-12 rounded-lg justify-center items-center mt-2.5 ${loading ? (isDarkMode ? 'bg-gray-600' : 'bg-gray-400') : (isDarkMode ? 'bg-blue-700' : 'bg-blue-600')}`}
onPress={createTeam}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text className="text-white text-base font-bold">Team erstellen</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
}
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich

View file

@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useTheme, lightColors, darkColors } from '../utils/themeContext';
export default function DashboardStats() {
const router = useRouter();
const { isDarkMode } = useTheme();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [teamCount, setTeamCount] = useState(0);
const [orgCount, setOrgCount] = useState(0);
const [availableMana, setAvailableMana] = useState(0);
const [totalMana, setTotalMana] = useState(0);
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchUserStats(session.user.id);
} else {
setLoading(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchUserStats(session.user.id);
} else {
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, []);
async function fetchUserStats(userId: string) {
try {
setLoading(true);
// Hole alle Teams, in denen der Benutzer Mitglied ist
const { data: teamMembers, error: teamMembersError } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', userId);
if (teamMembersError) throw teamMembersError;
setTeamCount(teamMembers?.length || 0);
// Hole alle Organisationen, in denen der Benutzer eine Rolle hat
const { data: userRoles, error: userRolesError } = await supabase
.from('user_roles')
.select('organization_id')
.eq('user_id', userId)
.not('organization_id', 'is', null);
if (userRolesError) throw userRolesError;
// Entferne Duplikate (falls der Benutzer mehrere Rollen in einer Organisation hat)
const uniqueOrgIds = [...new Set(userRoles?.map(role => role.organization_id) || [])];
setOrgCount(uniqueOrgIds.length);
// Hole die Mana-Informationen aus dem Profil des Benutzers
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('individual_quota, individual_usage')
.eq('id', userId)
.single();
if (profileError) throw profileError;
if (profileData) {
const quota = profileData.individual_quota || 0;
const usage = profileData.individual_usage || 0;
const available = Math.max(0, quota - usage);
setTotalMana(quota);
setAvailableMana(available);
}
} catch (error) {
console.error('Fehler beim Abrufen der Benutzerstatistiken:', error);
} finally {
setLoading(false);
}
}
if (!session || loading) {
return (
<View className={`flex-row justify-between ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 mb-5 shadow`}>
<ActivityIndicator size="small" color={isDarkMode ? '#60A5FA' : '#0055FF'} />
</View>
);
}
return (
<View className="mb-5">
{/* Mana-Anzeige */}
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 mb-3 shadow`}>
<TouchableOpacity
className="items-center"
onPress={() => router.push('/get-mana')}
>
<View className="flex-row items-center mb-2">
<FontAwesome5 name="fire" size={18} color="#F59E0B" className="mr-2" />
<Text className={`text-lg font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Verfügbares Mana</Text>
</View>
<View className="w-full bg-gray-200 rounded-full h-4 mb-2 dark:bg-gray-700">
<View
className="h-4 rounded-full"
style={{
width: totalMana > 0 ? `${Math.min(100, (availableMana / totalMana) * 100)}%` : '0%',
backgroundColor: isDarkMode ? darkColors.primary : lightColors.primary
}}
/>
</View>
<View className="flex-row justify-between w-full">
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>Verfügbar: {availableMana}</Text>
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>Gesamt: {totalMana}</Text>
</View>
</TouchableOpacity>
</View>
{/* Teams und Organisationen */}
<View className={`flex-row justify-between ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 shadow`}>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center ${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} rounded-lg p-3 mr-2`}
onPress={() => router.push('/teams')}
>
<View className="items-center">
<View className="flex-row items-center mb-1">
<FontAwesome5 name="users" size={16} color={isDarkMode ? '#60A5FA' : '#0055FF'} className="mr-2" />
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Teams</Text>
</View>
<View className={`${isDarkMode ? 'bg-blue-900' : 'bg-blue-100'} px-3 py-1 rounded-full`}>
<Text className={`${isDarkMode ? 'text-blue-300' : 'text-blue-700'} font-medium`}>{teamCount}</Text>
</View>
</View>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center ${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} rounded-lg p-3 ml-2`}
onPress={() => router.push('/organizations')}
>
<View className="items-center">
<View className="flex-row items-center mb-1">
<FontAwesome5 name="building" size={16} color={isDarkMode ? '#60A5FA' : '#0055FF'} className="mr-2" />
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Organisationen</Text>
</View>
<View className={`${isDarkMode ? 'bg-blue-900' : 'bg-blue-100'} px-3 py-1 rounded-full`}>
<Text className={`${isDarkMode ? 'text-blue-300' : 'text-blue-700'} font-medium`}>{orgCount}</Text>
</View>
</View>
</TouchableOpacity>
</View>
</View>
);
}

View file

@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -0,0 +1,31 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { forwardRef } from 'react';
import { Pressable, StyleSheet } from 'react-native';
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
);
export const styles = StyleSheet.create({
headerRight: {
marginRight: 15,
},
});

View file

@ -0,0 +1,304 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useTheme } from '../utils/themeContext';
import { useRouter } from 'expo-router';
interface Organization {
id: string;
name: string;
total_credits: number;
used_credits: number;
created_at: string;
team_count?: number;
user_role?: string;
}
interface OrganizationListProps {
hideTitle?: boolean;
}
// Definiere die Ref-Schnittstelle
interface OrganizationListRef {
refreshOrganizations: () => void;
}
const OrganizationList = forwardRef<OrganizationListRef, OrganizationListProps>(({ hideTitle = false }, ref) => {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const { isDarkMode } = useTheme();
const router = useRouter();
// Stelle die refreshOrganizations-Methode über die Ref zur Verfügung
useImperativeHandle(ref, () => ({
refreshOrganizations: () => {
if (session) {
console.log('Aktualisiere Organisationsliste');
fetchUserOrganizations(session.user.id);
}
}
}));
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchUserOrganizations(session.user.id);
} else {
setLoading(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchUserOrganizations(session.user.id);
} else {
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, []);
async function fetchUserOrganizations(userId: string) {
try {
setLoading(true);
// Hole alle Organisationen, in denen der Benutzer eine Rolle hat
const { data: userRoles, error: userRolesError } = await supabase
.from('user_roles')
.select('organization_id, role_id, roles(name)')
.eq('user_id', userId)
.not('organization_id', 'is', null) as {
data: Array<{
organization_id: string;
role_id: string;
roles: { name: string }
}> | null;
error: any
};
if (userRolesError) throw userRolesError;
if (userRoles && userRoles.length > 0) {
// Extrahiere die Organisations-IDs
const orgIds = [...new Set(userRoles.map(role => role.organization_id))];
// Hole die Organisations-Details
const { data: orgsData, error: orgsError } = await supabase
.from('organizations')
.select('id, name, total_credits, used_credits, created_at')
.in('id', orgIds);
if (orgsError) throw orgsError;
if (orgsData && orgsData.length > 0) {
// Hole die Anzahl der Teams für jede Organisation
const orgsWithTeamCount = await Promise.all(
orgsData.map(async (org) => {
// Finde die Rolle des Benutzers in dieser Organisation
const userRolesInOrg = userRoles.filter(role => role.organization_id === org.id);
const highestRole = findHighestRole(userRolesInOrg);
// Hole die Anzahl der Teams in dieser Organisation
const { count, error: teamCountError } = await supabase
.from('teams')
.select('id', { count: 'exact', head: true })
.eq('organization_id', org.id);
if (teamCountError) {
console.error('Fehler beim Abrufen der Team-Anzahl:', teamCountError);
return {
...org,
team_count: 0,
user_role: highestRole
};
}
return {
...org,
team_count: count || 0,
user_role: highestRole
};
})
);
setOrganizations(orgsWithTeamCount);
} else {
setOrganizations([]);
}
} else {
setOrganizations([]);
}
} catch (error) {
console.error('Fehler beim Abrufen der Organisationen:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden Ihrer Organisationen aufgetreten.');
} finally {
setLoading(false);
}
}
// Hilfsfunktion, um die höchste Rolle eines Benutzers zu finden
function findHighestRole(userRoles: Array<{ role_id: string; roles: { name: string } }>): string {
const roleHierarchy = {
'system_admin': 4,
'org_admin': 3,
'team_admin': 2,
'member': 1
};
let highestRoleName = 'member';
let highestRoleValue = 0;
userRoles.forEach(role => {
const roleName = role.roles?.name;
if (roleName && roleHierarchy[roleName as keyof typeof roleHierarchy] > highestRoleValue) {
highestRoleName = roleName;
highestRoleValue = roleHierarchy[roleName as keyof typeof roleHierarchy];
}
});
return highestRoleName;
}
// Keine Aufklappfunktion mehr benötigt
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Diese Funktion wird nicht mehr benötigt, da wir direkt mit NativeWind-Klassen arbeiten
const getRoleName = (role: string) => {
switch (role) {
case 'system_admin':
return 'System-Admin';
case 'org_admin':
return 'Organisations-Admin';
case 'team_admin':
return 'Team-Admin';
default:
return 'Mitglied';
}
};
if (!session) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Bitte melden Sie sich an, um Ihre Organisationen zu sehen.
</Text>
</View>
);
}
if (loading) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<ActivityIndicator size="large" color={isDarkMode ? '#93C5FD' : '#0055FF'} />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Lade Organisationen...</Text>
</View>
);
}
if (organizations.length === 0) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<FontAwesome5 name="building" size={50} color={isDarkMode ? '#4B5563' : '#ccc'} className="self-center mb-4" />
<Text className={`text-lg font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'} text-center mb-2.5`}>
Sie gehören derzeit keiner Organisation an.
</Text>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center px-5`}>
Erstellen Sie eine neue Organisation oder bitten Sie einen Administrator, Sie einer Organisation hinzuzufügen.
</Text>
</View>
);
}
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
{!hideTitle && (
<Text className={`text-2xl font-bold mb-4 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>Meine Organisationen</Text>
)}
<FlatList
data={organizations}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => {
console.log('Organisation angeklickt:', item.id, item.name);
router.push({
pathname: '/organizations/[id]',
params: { id: item.id, name: item.name }
});
}}
className={`${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} rounded-lg p-4 mb-2.5 shadow-sm`}
activeOpacity={0.7}
>
{/* Header mit Organisationsname */}
<View className="flex-row justify-between items-center mb-3">
<View className="flex-row items-center">
<FontAwesome5 name="building" size={18} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2.5" />
<Text className={`text-lg font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.name}</Text>
</View>
<View className={`px-2 py-0.5 rounded-full ${item.user_role === 'system_admin' ? 'bg-red-600' : item.user_role === 'org_admin' ? 'bg-orange-500' : item.user_role === 'team_admin' ? 'bg-green-600' : 'bg-gray-600'}`}>
<Text className="text-xs font-medium text-white">{getRoleName(item.user_role || 'member')}</Text>
</View>
</View>
{/* Mittlerer Bereich mit Teams und Erstellungsdatum */}
<View className="flex-row justify-between items-center mb-3">
<View className="flex-row items-center">
<FontAwesome5 name="users" size={14} color={isDarkMode ? '#9CA3AF' : '#666'} className="mr-2" />
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>{item.team_count} {item.team_count === 1 ? 'Team' : 'Teams'}</Text>
</View>
<View className="flex-row items-center">
<FontAwesome5 name="calendar-alt" size={14} color={isDarkMode ? '#9CA3AF' : '#666'} className="mr-2" />
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>{formatDate(item.created_at)}</Text>
</View>
</View>
{/* Unterer Bereich mit Kreditinformationen */}
<View className={`flex-row justify-between mt-1 pt-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<View className="flex-1 items-center">
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Gesamte Kredite</Text>
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.total_credits}</Text>
</View>
<View className={`flex-1 items-center border-x ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Verwendete Kredite</Text>
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.used_credits}</Text>
</View>
<View className="flex-1 items-center">
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Verfügbar</Text>
<Text
className={`text-base font-semibold ${item.total_credits - item.used_credits > 0 ? (isDarkMode ? 'text-green-400' : 'text-green-600') : (isDarkMode ? 'text-red-400' : 'text-red-600')}`}
>
{item.total_credits - item.used_credits}
</Text>
</View>
</View>
</TouchableOpacity>
)}
className="pb-5"
/>
</View>
);
});
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich
// Exportiere die Komponente mit forwardRef
export default OrganizationList;

View file

@ -0,0 +1,25 @@
import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -0,0 +1,593 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useTheme } from '../utils/themeContext';
interface User {
id: string;
email: string;
first_name?: string;
last_name?: string;
}
export default function SendMana() {
const { isDarkMode } = useTheme();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const [recipientEmail, setRecipientEmail] = useState('');
const [manaAmount, setManaAmount] = useState('');
const [description, setDescription] = useState('');
const [foundUser, setFoundUser] = useState<User | null>(null);
const [userCredits, setUserCredits] = useState(0);
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchUserCredits(session.user.id);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchUserCredits(session.user.id);
}
});
return () => subscription.unsubscribe();
}, []);
async function fetchUserCredits(userId: string) {
try {
// Zuerst prüfen wir, ob der Benutzer ein Einzelnutzer ist
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('is_individual, individual_quota, individual_usage')
.eq('id', userId)
.single();
if (profileError) throw profileError;
if (profileData && profileData.is_individual) {
// Wenn der Benutzer ein Einzelnutzer ist, verwenden wir die individuellen Kredite
setUserCredits(profileData.individual_quota - profileData.individual_usage);
return;
}
// Wenn der Benutzer kein Einzelnutzer ist, prüfen wir die Teamzugehörigkeit
const { data: teamMemberData, error: teamMemberError } = await supabase
.from('team_members')
.select('allocated_credits, used_credits')
.eq('user_id', userId);
if (teamMemberError) throw teamMemberError;
if (teamMemberData && teamMemberData.length > 0) {
// Berechne die Summe der verfügbaren Kredite aus allen Teams
const availableCredits = teamMemberData.reduce((total, member) => {
return total + (member.allocated_credits - member.used_credits);
}, 0);
setUserCredits(availableCredits);
} else {
setUserCredits(0);
}
} catch (error) {
console.error('Fehler beim Abrufen der Kredite:', error);
setUserCredits(0);
}
}
async function searchUser() {
if (!recipientEmail.trim()) {
Alert.alert('Fehler', 'Bitte geben Sie eine E-Mail-Adresse ein.');
return;
}
try {
setSearchLoading(true);
// In einer realen Anwendung würden wir hier eine sichere Methode verwenden,
// um Benutzer anhand ihrer E-Mail-Adresse zu finden, z.B. über eine spezielle API
// oder eine Supabase-Funktion. Für dieses Beispiel simulieren wir die Suche.
// Suche nach dem Profil mit einer bestimmten E-Mail
// Hinweis: In einer echten Anwendung würde dies über eine sichere API erfolgen
const { data: profiles, error: profileError } = await supabase
.from('profiles')
.select('id, first_name, last_name')
.limit(1);
if (profileError || !profiles || profiles.length === 0) {
Alert.alert('Fehler', 'Benutzer nicht gefunden.');
setFoundUser(null);
return;
}
// Für Demonstrationszwecke verwenden wir das erste gefundene Profil
// In einer realen Anwendung würden wir natürlich das richtige Profil finden
const profile = profiles[0];
setFoundUser({
id: profile.id,
email: recipientEmail.trim(),
first_name: profile.first_name || undefined,
last_name: profile.last_name || undefined
});
} catch (error) {
console.error('Fehler bei der Benutzersuche:', error);
Alert.alert('Fehler', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.');
} finally {
setSearchLoading(false);
}
}
async function sendMana() {
if (!session) {
Alert.alert('Fehler', 'Sie müssen angemeldet sein, um Mana zu senden.');
return;
}
if (!foundUser) {
Alert.alert('Fehler', 'Bitte suchen Sie zuerst nach einem Empfänger.');
return;
}
const amount = parseInt(manaAmount);
if (isNaN(amount) || amount <= 0) {
Alert.alert('Fehler', 'Bitte geben Sie einen gültigen Mana-Betrag ein.');
return;
}
if (amount > userCredits) {
Alert.alert('Fehler', 'Sie haben nicht genügend Mana-Kredite.');
return;
}
try {
setLoading(true);
// 1. Erstelle einen Eintrag in der credit_transactions Tabelle
const { error: transactionError } = await supabase
.from('credit_transactions')
.insert([
{
user_id: foundUser.id,
amount: amount,
description: description || 'Mana-Übertragung',
app_id: 'manacore'
}
]);
if (transactionError) throw transactionError;
// 2. Aktualisiere die Kredite des Empfängers
const { data: recipientProfile, error: recipientProfileError } = await supabase
.from('profiles')
.select('is_individual, individual_quota')
.eq('id', foundUser.id)
.single();
if (recipientProfileError) throw recipientProfileError;
if (recipientProfile && recipientProfile.is_individual) {
// Wenn der Empfänger ein Einzelnutzer ist, erhöhen wir sein Kontingent
const { error: updateError } = await supabase
.from('profiles')
.update({
individual_quota: recipientProfile.individual_quota + amount
})
.eq('id', foundUser.id);
if (updateError) throw updateError;
}
// 3. Aktualisiere die Kredite des Senders
const { data: senderProfile, error: senderProfileError } = await supabase
.from('profiles')
.select('is_individual, individual_usage')
.eq('id', session.user.id)
.single();
if (senderProfileError) throw senderProfileError;
if (senderProfile && senderProfile.is_individual) {
// Wenn der Sender ein Einzelnutzer ist, erhöhen wir seine Nutzung
const { error: updateError } = await supabase
.from('profiles')
.update({
individual_usage: senderProfile.individual_usage + amount
})
.eq('id', session.user.id);
if (updateError) throw updateError;
} else {
// Wenn der Sender kein Einzelnutzer ist, müssen wir prüfen, ob er zu einem Team gehört
// und die entsprechenden Kredite aktualisieren
// Diese Logik würde komplexer sein und hängt von Ihrer spezifischen Implementierung ab
}
Alert.alert('Erfolg', `${amount} Mana wurden erfolgreich an ${foundUser.email} gesendet.`);
// Formular zurücksetzen
setRecipientEmail('');
setManaAmount('');
setDescription('');
setFoundUser(null);
// Aktualisiere die Kredite des Benutzers
fetchUserCredits(session.user.id);
} catch (error) {
console.error('Fehler beim Senden von Mana:', error);
Alert.alert('Fehler', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.');
} finally {
setLoading(false);
}
}
// Dynamische Stile basierend auf dem aktuellen Theme
const styles = StyleSheet.create({
container: {
backgroundColor: isDarkMode ? '#1E1E1E' : 'white',
borderRadius: 10,
padding: 20,
marginBottom: 20,
shadowColor: isDarkMode ? '#000000' : '#000000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: isDarkMode ? 0.5 : 0.1,
shadowRadius: 4,
elevation: 3,
},
header: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 20,
color: isDarkMode ? '#F9FAFB' : '#333',
},
creditInfo: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: isDarkMode ? '#1E3A8A' : '#f0f8ff',
padding: 15,
borderRadius: 8,
marginBottom: 20,
},
creditLabel: {
fontSize: 16,
fontWeight: '500',
color: isDarkMode ? '#E5E7EB' : '#333',
},
creditAmount: {
fontSize: 18,
fontWeight: 'bold',
color: isDarkMode ? '#93C5FD' : '#0055FF',
marginLeft: 10,
},
formGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 8,
fontWeight: '500',
color: isDarkMode ? '#E5E7EB' : '#333',
},
input: {
height: 50,
borderWidth: 1,
borderColor: isDarkMode ? '#4B5563' : '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
backgroundColor: isDarkMode ? '#374151' : '#f9f9f9',
fontSize: 16,
color: isDarkMode ? '#F9FAFB' : '#333',
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
},
searchInput: {
flex: 1,
height: 50,
borderWidth: 1,
borderColor: isDarkMode ? '#4B5563' : '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
backgroundColor: isDarkMode ? '#374151' : '#f9f9f9',
fontSize: 16,
color: isDarkMode ? '#F9FAFB' : '#333',
},
searchButton: {
backgroundColor: isDarkMode ? '#3B82F6' : '#0055FF',
height: 50,
paddingHorizontal: 15,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 10,
},
searchButtonText: {
color: 'white',
fontSize: 14,
fontWeight: 'bold',
},
userCard: {
backgroundColor: isDarkMode ? '#1E3A8A' : '#f0f8ff',
padding: 15,
borderRadius: 8,
marginBottom: 20,
borderLeftWidth: 4,
borderLeftColor: isDarkMode ? '#3B82F6' : '#0055FF',
},
userCardTitle: {
fontSize: 14,
color: isDarkMode ? '#9CA3AF' : '#666',
marginBottom: 5,
},
userCardEmail: {
fontSize: 16,
fontWeight: 'bold',
color: isDarkMode ? '#F9FAFB' : '#333',
},
userCardName: {
fontSize: 14,
color: isDarkMode ? '#9CA3AF' : '#666',
marginTop: 5,
},
sendButton: {
backgroundColor: isDarkMode ? '#3B82F6' : '#0055FF',
height: 50,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 10,
},
sendButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
disabledButton: {
backgroundColor: isDarkMode ? '#4B5563' : '#ccc',
},
notLoggedIn: {
fontSize: 16,
color: isDarkMode ? '#9CA3AF' : '#666',
textAlign: 'center',
padding: 20,
},
});
if (!session) {
return (
<View style={styles.container}>
<Text style={styles.notLoggedIn}>
Bitte melden Sie sich an, um Mana zu senden.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.header}>Mana senden</Text>
<View style={styles.creditInfo}>
<Text style={styles.creditLabel}>Verfügbares Mana:</Text>
<Text style={styles.creditAmount}>{userCredits}</Text>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Empfänger E-Mail</Text>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
value={recipientEmail}
onChangeText={setRecipientEmail}
placeholder="E-Mail des Empfängers"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
autoCapitalize="none"
keyboardType="email-address"
/>
<TouchableOpacity
style={styles.searchButton}
onPress={searchUser}
disabled={searchLoading}
>
{searchLoading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.searchButtonText}>Suchen</Text>
)}
</TouchableOpacity>
</View>
</View>
{foundUser && (
<View style={styles.userCard}>
<Text style={styles.userCardTitle}>Empfänger gefunden:</Text>
<Text style={styles.userCardEmail}>{foundUser.email}</Text>
{(foundUser.first_name || foundUser.last_name) && (
<Text style={styles.userCardName}>
{[foundUser.first_name, foundUser.last_name].filter(Boolean).join(' ')}
</Text>
)}
</View>
)}
<View style={styles.formGroup}>
<Text style={styles.label}>Mana-Betrag</Text>
<TextInput
style={styles.input}
value={manaAmount}
onChangeText={setManaAmount}
placeholder="Betrag eingeben"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
keyboardType="number-pad"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Beschreibung (optional)</Text>
<TextInput
style={styles.input}
value={description}
onChangeText={setDescription}
placeholder="Grund für die Übertragung"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#9CA3AF'}
multiline
/>
</View>
<TouchableOpacity
style={[
styles.sendButton,
(!foundUser || loading) && styles.disabledButton
]}
onPress={sendMana}
disabled={!foundUser || loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.sendButtonText}>Mana senden</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
borderRadius: 10,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
header: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 20,
color: '#333',
},
creditInfo: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f0f8ff',
padding: 15,
borderRadius: 8,
marginBottom: 20,
},
creditLabel: {
fontSize: 16,
fontWeight: '500',
color: '#333',
},
creditAmount: {
fontSize: 18,
fontWeight: 'bold',
color: '#0055FF',
marginLeft: 10,
},
formGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 8,
fontWeight: '500',
color: '#333',
},
input: {
height: 50,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
backgroundColor: '#f9f9f9',
fontSize: 16,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
},
searchInput: {
flex: 1,
height: 50,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
backgroundColor: '#f9f9f9',
fontSize: 16,
},
searchButton: {
backgroundColor: '#0055FF',
height: 50,
paddingHorizontal: 15,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 10,
},
searchButtonText: {
color: 'white',
fontSize: 14,
fontWeight: 'bold',
},
userCard: {
backgroundColor: '#f0f8ff',
padding: 15,
borderRadius: 8,
marginBottom: 20,
borderLeftWidth: 4,
borderLeftColor: '#0055FF',
},
userCardTitle: {
fontSize: 14,
color: '#666',
marginBottom: 5,
},
userCardEmail: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
},
userCardName: {
fontSize: 14,
color: '#666',
marginTop: 5,
},
sendButton: {
backgroundColor: '#0055FF',
height: 50,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 10,
},
sendButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
disabledButton: {
backgroundColor: '#ccc',
},
notLoggedIn: {
fontSize: 16,
color: '#666',
textAlign: 'center',
padding: 20,
},
});

View file

@ -0,0 +1,15 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { StyleSheet } from 'react-native';
export const TabBarIcon = (props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) => {
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
});

View file

@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, Alert, Platform } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useRouter } from 'expo-router';
import { useTheme } from '../utils/themeContext';
interface Team {
id: string;
name: string;
organization_id: string;
organization_name?: string;
allocated_credits?: number;
used_credits?: number;
created_at: string;
}
interface TeamListProps {
hideTitle?: boolean;
}
// Funktion zum Aktualisieren der Teamliste, die von außen aufgerufen werden kann
export function refreshTeamList(userId: string, callback?: () => void) {
if (!userId) return;
// Hole alle Teams, in denen der Benutzer Mitglied ist
supabase
.from('team_members')
.select('team_id, allocated_credits, used_credits')
.eq('user_id', userId)
.then(({ data: teamMembers, error: teamMembersError }) => {
if (teamMembersError) {
console.error('Fehler beim Abrufen der Team-Mitglieder:', teamMembersError);
if (callback) callback();
return;
}
if (teamMembers && teamMembers.length > 0) {
// Extrahiere die Team-IDs
const teamIds = teamMembers.map(member => member.team_id);
// Hole die Team-Details
supabase
.from('teams')
.select('id, name, organization_id, created_at')
.in('id', teamIds)
.then(({ data: teamsData, error: teamsError }) => {
if (teamsError) {
console.error('Fehler beim Abrufen der Teams:', teamsError);
if (callback) callback();
return;
}
if (teamsData && teamsData.length > 0) {
// Hole die Organisationsnamen für jedes Team
const orgIds = [...new Set(teamsData.map(team => team.organization_id))];
supabase
.from('organizations')
.select('id, name')
.in('id', orgIds)
.then(({ data: orgsData, error: orgsError }) => {
if (orgsError) {
console.error('Fehler beim Abrufen der Organisationen:', orgsError);
if (callback) callback();
return;
}
// Globales Event auslösen, um alle TeamList-Komponenten zu aktualisieren
const event = new CustomEvent('teamlist-refresh', {
detail: {
teams: teamsData.map(team => {
const org = orgsData?.find(org => org.id === team.organization_id);
const memberInfo = teamMembers.find(member => member.team_id === team.id);
return {
...team,
organization_name: org?.name || 'Unbekannte Organisation',
allocated_credits: memberInfo?.allocated_credits || 0,
used_credits: memberInfo?.used_credits || 0
};
})
}
});
// Event auslösen
if (typeof window !== 'undefined') {
window.dispatchEvent(event);
}
if (callback) callback();
});
} else {
// Globales Event mit leerer Liste auslösen
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('teamlist-refresh', { detail: { teams: [] } }));
}
if (callback) callback();
}
});
} else {
// Globales Event mit leerer Liste auslösen
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('teamlist-refresh', { detail: { teams: [] } }));
}
if (callback) callback();
}
});
}
export default function TeamList({ hideTitle = false }: TeamListProps) {
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [teams, setTeams] = useState<Team[]>([]);
const { isDarkMode } = useTheme();
useEffect(() => {
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchUserTeams(session.user.id);
} else {
setLoading(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchUserTeams(session.user.id);
} else {
setLoading(false);
}
});
// Listener für teamlist-refresh Event hinzufügen
const handleTeamListRefresh = (event: any) => {
if (event.detail && event.detail.teams) {
setTeams(event.detail.teams);
setLoading(false);
}
};
if (typeof window !== 'undefined') {
window.addEventListener('teamlist-refresh', handleTeamListRefresh);
}
return () => {
subscription.unsubscribe();
if (typeof window !== 'undefined') {
window.removeEventListener('teamlist-refresh', handleTeamListRefresh);
}
};
}, []);
async function fetchUserTeams(userId: string) {
try {
setLoading(true);
// Hole alle Teams, in denen der Benutzer Mitglied ist
const { data: teamMembers, error: teamMembersError } = await supabase
.from('team_members')
.select('team_id, allocated_credits, used_credits')
.eq('user_id', userId);
if (teamMembersError) throw teamMembersError;
if (teamMembers && teamMembers.length > 0) {
// Extrahiere die Team-IDs
const teamIds = teamMembers.map(member => member.team_id);
// Hole die Team-Details
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('id, name, organization_id, created_at')
.in('id', teamIds);
if (teamsError) throw teamsError;
if (teamsData && teamsData.length > 0) {
// Hole die Organisationsnamen für jedes Team
const orgIds = [...new Set(teamsData.map(team => team.organization_id))];
const { data: orgsData, error: orgsError } = await supabase
.from('organizations')
.select('id, name')
.in('id', orgIds);
if (orgsError) throw orgsError;
// Füge Organisationsnamen und Kredit-Informationen zu den Teams hinzu
const enhancedTeams = teamsData.map(team => {
const org = orgsData?.find(org => org.id === team.organization_id);
const memberInfo = teamMembers.find(member => member.team_id === team.id);
return {
...team,
organization_name: org?.name || 'Unbekannte Organisation',
allocated_credits: memberInfo?.allocated_credits || 0,
used_credits: memberInfo?.used_credits || 0
};
});
setTeams(enhancedTeams);
} else {
setTeams([]);
}
} else {
setTeams([]);
}
} catch (error) {
console.error('Fehler beim Abrufen der Teams:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden Ihrer Teams aufgetreten.');
} finally {
setLoading(false);
}
}
// Keine Aufklappfunktion mehr benötigt
const navigateToTeamDetails = (teamId: string, teamName: string) => {
// Verwende die korrekte Expo Router Navigation
router.push({
pathname: '/teams/[id]',
params: { id: teamId, name: teamName }
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
if (!session) {
return (
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5 mx-2.5`}>
Bitte melden Sie sich an, um Ihre Teams zu sehen.
</Text>
);
}
if (loading) {
return (
<View className="flex-1 items-center justify-center p-4 mx-2.5">
<ActivityIndicator size="large" color="#0055FF" />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Lade Teams...</Text>
</View>
);
}
if (teams.length === 0) {
return (
<View className="flex-1 items-center justify-center p-4 mx-2.5">
<FontAwesome5 name="users-slash" size={50} color={isDarkMode ? '#4B5563' : '#ccc'} className="mb-4" />
<Text className={`text-lg font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'} text-center mb-2.5`}>
Sie sind derzeit kein Mitglied in einem Team.
</Text>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center px-5`}>
Erstellen Sie ein neues Team oder bitten Sie einen Administrator, Sie einem Team hinzuzufügen.
</Text>
</View>
);
}
return (
<>
{!hideTitle && (
<Text className={`text-2xl font-bold mb-4 ${isDarkMode ? 'text-gray-100' : 'text-gray-800'} px-2.5`}>Meine Teams</Text>
)}
<FlatList
data={teams}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
className={`${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} rounded-lg p-4 mb-2.5 shadow-sm`}
style={Platform.OS === 'web' ? { cursor: 'pointer' } : undefined}
onPress={() => navigateToTeamDetails(item.id, item.name)}
activeOpacity={0.7}
>
{/* Header mit Teamname */}
<View className="flex-row justify-between items-center mb-3">
<View className="flex-row items-center">
<FontAwesome5 name="users" size={18} color={isDarkMode ? '#93C5FD' : '#0055FF'} className="mr-2.5" />
<Text className={`text-lg font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.name}</Text>
</View>
<FontAwesome5 name="chevron-right" size={14} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
</View>
{/* Mittlerer Bereich mit Organisation und Erstellungsdatum */}
<View className="flex-row justify-between items-center mb-3">
<View className="flex-row items-center">
<FontAwesome5 name="building" size={14} color={isDarkMode ? '#9CA3AF' : '#666'} className="mr-2" />
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>{item.organization_name}</Text>
</View>
<View className="flex-row items-center">
<FontAwesome5 name="calendar-alt" size={14} color={isDarkMode ? '#9CA3AF' : '#666'} className="mr-2" />
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>{formatDate(item.created_at)}</Text>
</View>
</View>
{/* Unterer Bereich mit Kreditinformationen */}
<View className={`flex-row justify-between mt-1 pt-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<View className="flex-1 items-center">
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Zugewiesen</Text>
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.allocated_credits}</Text>
</View>
<View className={`flex-1 items-center border-x ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Verwendet</Text>
<Text className={`text-base font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{item.used_credits}</Text>
</View>
<View className="flex-1 items-center">
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mb-1`}>Verfügbar</Text>
<Text
className={`text-base font-semibold ${(item.allocated_credits || 0) - (item.used_credits || 0) > 0 ? (isDarkMode ? 'text-green-400' : 'text-green-600') : (isDarkMode ? 'text-red-400' : 'text-red-600')}`}
>
{(item.allocated_credits || 0) - (item.used_credits || 0)}
</Text>
</View>
</View>
</TouchableOpacity>
)}
className="pb-5 px-2.5"
/>
</>
);
}
// NativeWind wird für das Styling verwendet, daher sind keine StyleSheet-Definitionen erforderlich

View file

@ -0,0 +1,664 @@
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, Alert, TextInput } from 'react-native';
import { FontAwesome5 } from '@expo/vector-icons';
import { supabase } from '../utils/supabase';
import { Session } from '@supabase/supabase-js';
import { useLocalSearchParams } from 'expo-router';
import { useTheme } from '../utils/themeContext';
interface TeamMember {
// Anpassung an die tatsächliche Datenbankstruktur
user_id: string;
team_id: string;
allocated_credits: number;
used_credits: number;
first_name?: string;
last_name?: string;
created_at: string;
updated_at?: string;
}
interface TeamDetails {
id: string;
name: string;
organization_id: string;
organization_name?: string;
created_at: string;
}
interface TeamMembersProps {
teamId: string;
}
export default function TeamMembers({ teamId }: TeamMembersProps) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [teamDetails, setTeamDetails] = useState<TeamDetails | null>(null);
const [members, setMembers] = useState<TeamMember[]>([]);
const [newMemberEmail, setNewMemberEmail] = useState('');
const [inviting, setInviting] = useState(false);
const [userRole, setUserRole] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const { isDarkMode } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [updatingName, setUpdatingName] = useState(false);
// Zustandsvariablen für die Bearbeitung der Mitgliederlimits
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [newCreditLimit, setNewCreditLimit] = useState('');
const [updatingLimit, setUpdatingLimit] = useState(false);
useEffect(() => {
if (!teamId) {
Alert.alert('Fehler', 'Team-ID nicht gefunden');
return;
}
// Setze den neuen Teamnamen, wenn sich die Teamdetails ändern
if (teamDetails) {
setNewTeamName(teamDetails.name);
}
// Prüfe den aktuellen Authentifizierungsstatus
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) {
fetchTeamDetails(teamId);
fetchTeamMembers(teamId);
checkUserRole(session.user.id, teamId);
} else {
setLoading(false);
}
});
// Abonniere Authentifizierungsänderungen
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) {
fetchTeamDetails(teamId);
fetchTeamMembers(teamId);
checkUserRole(session.user.id, teamId);
} else {
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, [teamId]);
async function checkUserRole(userId: string, teamId: string) {
try {
// Vereinfachte Version: Wir setzen isAdmin auf true, damit die Optionen zum Hinzufügen und Entfernen
// von Teammitgliedern immer angezeigt werden
setIsAdmin(true);
setUserRole('admin');
// Prüfe, ob der Benutzer ein Teammitglied ist
const { data: memberData, error: memberError } = await supabase
.from('team_members')
.select('user_id, team_id')
.eq('user_id', userId)
.eq('team_id', teamId);
if (memberError) {
console.error('Fehler beim Prüfen der Mitgliedschaft:', memberError);
}
// Wenn der Benutzer kein Mitglied ist, setzen wir die Rolle auf null
if (!memberData || memberData.length === 0) {
setUserRole(null);
}
} catch (error) {
console.error('Fehler beim Prüfen der Benutzerrolle:', error);
}
}
async function fetchTeamDetails(teamId: string) {
try {
const { data, error } = await supabase
.from('teams')
.select('id, name, organization_id, created_at')
.eq('id', teamId)
.single();
if (error) throw error;
if (data) {
// Hole den Organisationsnamen
const { data: orgData, error: orgError } = await supabase
.from('organizations')
.select('name')
.eq('id', data.organization_id)
.single();
if (orgError) throw orgError;
setTeamDetails({
...data,
organization_name: orgData?.name || 'Unbekannte Organisation'
});
// Setze den neuen Teamnamen
setNewTeamName(data.name);
}
} catch (error) {
console.error('Fehler beim Abrufen der Team-Details:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden der Team-Details aufgetreten.');
}
}
const startEditing = () => {
setIsEditing(true);
if (teamDetails) {
setNewTeamName(teamDetails.name);
}
};
const cancelEditing = () => {
setIsEditing(false);
if (teamDetails) {
setNewTeamName(teamDetails.name);
}
};
const updateTeamName = async () => {
if (!newTeamName.trim()) {
Alert.alert('Fehler', 'Der Teamname darf nicht leer sein.');
return;
}
if (teamDetails && newTeamName.trim() === teamDetails.name) {
setIsEditing(false);
return;
}
setUpdatingName(true);
try {
const { error } = await supabase
.from('teams')
.update({ name: newTeamName.trim() })
.eq('id', teamId);
if (error) throw error;
// Aktualisiere die Teamdetails
if (teamDetails) {
setTeamDetails({
...teamDetails,
name: newTeamName.trim()
});
}
setIsEditing(false);
Alert.alert('Erfolg', 'Der Teamname wurde erfolgreich aktualisiert.');
} catch (error) {
console.error('Fehler beim Aktualisieren des Teamnamens:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Aktualisieren des Teamnamens aufgetreten.');
} finally {
setUpdatingName(false);
}
};
async function fetchTeamMembers(teamId: string) {
try {
setLoading(true);
// Hole alle Teammitglieder basierend auf der tatsächlichen Datenbankstruktur
const { data: memberData, error: memberError } = await supabase
.from('team_members')
.select('user_id, team_id, allocated_credits, used_credits, created_at, updated_at')
.eq('team_id', teamId);
if (memberError) throw memberError;
if (memberData && memberData.length > 0) {
// Hole die Benutzerprofile für jedes Mitglied
const userIds = memberData.map(member => member.user_id);
// Abfrage der Profilinformationen aus der profiles-Tabelle
const { data: userData, error: userError } = await supabase
.from('profiles')
.select('id, first_name, last_name')
.in('id', userIds);
if (userError) throw userError;
// Füge Benutzerinformationen zu den Mitgliedern hinzu
const enhancedMembers = memberData.map(member => {
const user = userData?.find(u => u.id === member.user_id);
return {
...member,
first_name: user?.first_name,
last_name: user?.last_name
};
});
setMembers(enhancedMembers as TeamMember[]);
} else {
setMembers([]);
}
} catch (error) {
console.error('Fehler beim Abrufen der Teammitglieder:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Laden der Teammitglieder aufgetreten.');
} finally {
setLoading(false);
}
}
async function inviteMember() {
if (!newMemberEmail || !newMemberEmail.includes('@')) {
Alert.alert('Fehler', 'Bitte geben Sie eine gültige E-Mail-Adresse ein.');
return;
}
if (!teamId || !isAdmin) {
Alert.alert('Fehler', 'Sie haben keine Berechtigung, Mitglieder einzuladen.');
return;
}
try {
setInviting(true);
// Suche nach dem Benutzer mit dieser E-Mail
const { data: profiles, error: profilesError } = await supabase
.from('profiles')
.select('id, email')
.ilike('email', newMemberEmail.trim().toLowerCase());
if (profilesError) {
console.error('Fehler beim Suchen des Benutzers:', profilesError);
Alert.alert('Fehler', 'Benutzer konnte nicht gefunden werden.');
return;
}
if (!profiles || profiles.length === 0) {
// Wenn der Benutzer nicht gefunden wurde, zeigen wir eine Meldung an
Alert.alert(
'Benutzer nicht gefunden',
'Möchten Sie eine Einladung an diese E-Mail-Adresse senden?',
[
{
text: 'Abbrechen',
style: 'cancel'
},
{
text: 'Einladen',
onPress: () => {
// Hier könnte eine Einladungs-E-Mail gesendet werden
// Für jetzt zeigen wir nur eine Erfolgsmeldung an
Alert.alert('Erfolg', `Eine Einladung wurde an ${newMemberEmail} gesendet.`);
setNewMemberEmail('');
}
}
]
);
return;
}
const userId = profiles[0].id;
// Prüfe, ob der Benutzer bereits Mitglied ist
const { data: existingMember, error: existingError } = await supabase
.from('team_members')
.select('user_id, team_id')
.eq('user_id', userId)
.eq('team_id', teamId);
if (existingError) throw existingError;
if (existingMember && existingMember.length > 0) {
Alert.alert('Information', 'Dieser Benutzer ist bereits Mitglied des Teams.');
return;
}
// Füge den Benutzer zum Team hinzu
const { error: addError } = await supabase
.from('team_members')
.insert([
{
user_id: userId,
team_id: teamId,
allocated_credits: 0,
used_credits: 0
}
]);
if (addError) throw addError;
Alert.alert('Erfolg', 'Benutzer wurde erfolgreich zum Team hinzugefügt.');
setNewMemberEmail('');
fetchTeamMembers(teamId);
} catch (error) {
console.error('Fehler beim Einladen des Benutzers:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Einladen des Benutzers aufgetreten.');
} finally {
setInviting(false);
}
}
// Direktes Entfernen ohne Alert-Dialog
async function removeMember(userId: string, memberName: string) {
if (!isAdmin) {
Alert.alert('Fehler', 'Sie haben keine Berechtigung, Mitglieder zu entfernen.');
return;
}
console.log(`Versuche, Mitglied zu entfernen: ${memberName} (${userId}) aus Team ${teamId}`);
try {
console.log('Sende Löschanfrage an Supabase...');
console.log(`Parameter: user_id=${userId}, team_id=${teamId}`);
// Beachte, dass team_members einen zusammengesetzten Primärschlüssel aus team_id und user_id hat
const { data, error } = await supabase
.from('team_members')
.delete()
.eq('user_id', userId)
.eq('team_id', teamId)
.select();
if (error) {
console.error('Supabase-Fehler beim Entfernen:', error);
Alert.alert('Fehler', `Fehler beim Entfernen des Mitglieds: ${error.message}`);
return;
}
console.log('Löschantwort von Supabase:', data);
Alert.alert('Erfolg', 'Mitglied wurde erfolgreich aus dem Team entfernt.');
// Aktualisiere die Mitgliederliste
fetchTeamMembers(teamId);
} catch (error) {
console.error('Fehler beim Entfernen des Mitglieds:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Entfernen des Mitglieds aufgetreten. Bitte überprüfen Sie die Konsole für weitere Details.');
}
}
// Funktion zum Starten der Bearbeitung des Credit-Limits für ein Mitglied
const startEditingLimit = (userId: string, currentLimit: number) => {
if (!isAdmin) {
Alert.alert('Fehler', 'Sie haben keine Berechtigung, Mitgliederlimits zu bearbeiten.');
return;
}
setEditingMemberId(userId);
setNewCreditLimit(currentLimit.toString());
};
// Funktion zum Abbrechen der Bearbeitung
const cancelEditingLimit = () => {
setEditingMemberId(null);
setNewCreditLimit('');
};
// Funktion zum Aktualisieren des Credit-Limits eines Mitglieds
const updateMemberLimit = async () => {
if (!editingMemberId || !teamId) {
Alert.alert('Fehler', 'Mitglied oder Team-ID nicht gefunden.');
return;
}
const creditLimit = parseInt(newCreditLimit);
if (isNaN(creditLimit) || creditLimit < 0) {
Alert.alert('Fehler', 'Bitte geben Sie einen gültigen Wert für das Credit-Limit ein.');
return;
}
setUpdatingLimit(true);
try {
// Aktualisiere das Credit-Limit in der Datenbank
const { error } = await supabase
.from('team_members')
.update({ allocated_credits: creditLimit })
.eq('user_id', editingMemberId)
.eq('team_id', teamId);
if (error) throw error;
// Aktualisiere die lokale Mitgliederliste
setMembers(members.map(member => {
if (member.user_id === editingMemberId) {
return { ...member, allocated_credits: creditLimit };
}
return member;
}));
Alert.alert('Erfolg', 'Das Credit-Limit wurde erfolgreich aktualisiert.');
setEditingMemberId(null);
} catch (error) {
console.error('Fehler beim Aktualisieren des Credit-Limits:', error);
Alert.alert('Fehler', 'Es ist ein Fehler beim Aktualisieren des Credit-Limits aufgetreten.');
} finally {
setUpdatingLimit(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
if (!session) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center p-5`}>
Bitte melden Sie sich an, um Teammitglieder zu sehen.
</Text>
</View>
);
}
if (loading) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<ActivityIndicator size="large" color="#0055FF" />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-2.5`}>Lade Teammitglieder...</Text>
</View>
);
}
return (
<View className="flex-1">
{teamDetails && (
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
{isEditing ? (
<View>
<View className="flex-row items-center mb-3">
<Text className={`text-lg font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'} mr-2`}>Teamname bearbeiten:</Text>
</View>
<View className="flex-row items-center">
<TextInput
className={`flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-white' : 'bg-gray-50 border-gray-300 text-gray-800'} border rounded-lg px-3 py-2 mr-2`}
value={newTeamName}
onChangeText={setNewTeamName}
placeholder="Teamname"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
/>
</View>
<View className="flex-row justify-end mt-3 mb-2">
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg mr-2 ${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'}`}
onPress={cancelEditing}
disabled={updatingName}
>
<Text className={`${isDarkMode ? 'text-gray-300' : 'text-gray-700'} font-semibold`}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-row items-center py-2 px-4 rounded-lg ${isDarkMode ? 'bg-blue-700' : 'bg-blue-600'}`}
onPress={updateTeamName}
disabled={updatingName}
>
{updatingName ? (
<Text className="text-white font-semibold">Speichern...</Text>
) : (
<Text className="text-white font-semibold">Speichern</Text>
)}
</TouchableOpacity>
</View>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-3`}>
Organisation: {teamDetails.organization_name}
</Text>
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Erstellt am: {formatDate(teamDetails.created_at)}
</Text>
</View>
) : (
<View>
<View className="flex-row items-center justify-between mb-1">
<Text className={`text-xl font-bold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>{teamDetails.name}</Text>
{isAdmin && (
<TouchableOpacity
className={`p-2 rounded-full ${isDarkMode ? 'bg-gray-700' : 'bg-gray-100'}`}
onPress={startEditing}
>
<FontAwesome5 name="edit" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
</TouchableOpacity>
)}
</View>
<Text className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-3`}>
Organisation: {teamDetails.organization_name}
</Text>
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Erstellt am: {formatDate(teamDetails.created_at)}
</Text>
</View>
)}
</View>
)}
<View className={`${isDarkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg p-4 m-2.5 shadow`}>
<Text className={`text-lg font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'} mb-4`}>Teammitglieder</Text>
{isAdmin && (
<View className={`mb-4 ${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} p-3 rounded-lg`}>
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'} mb-2`}>Neues Mitglied einladen</Text>
<View className="flex-row">
<TextInput
className={`flex-1 ${isDarkMode ? 'bg-gray-600 border-gray-500 text-white' : 'bg-white border-gray-300 text-gray-800'} border rounded-lg px-3 py-2 mr-2`}
placeholder="E-Mail-Adresse"
placeholderTextColor={isDarkMode ? '#9CA3AF' : '#6B7280'}
value={newMemberEmail}
onChangeText={setNewMemberEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TouchableOpacity
className="bg-blue-600 rounded-lg px-4 py-2 items-center justify-center"
onPress={inviteMember}
disabled={inviting}
>
{inviting ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-white font-medium">Einladen</Text>
)}
</TouchableOpacity>
</View>
</View>
)}
{members.length === 0 ? (
<View className="items-center justify-center py-8">
<FontAwesome5 name="users-slash" size={50} color={isDarkMode ? '#4B5563' : '#ccc'} />
<Text className={`text-base ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-center mt-4`}>
Keine Mitglieder in diesem Team gefunden.
</Text>
</View>
) : (
<FlatList
data={members}
keyExtractor={(item) => item.user_id}
renderItem={({ item }) => (
<View className={`flex-row items-center justify-between ${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'} p-3 rounded-lg mb-2`}>
<View className="flex-1">
<Text className={`text-base font-medium ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}>
{item.first_name && item.last_name
? `${item.first_name} ${item.last_name}`
: `Benutzer ${item.user_id.substring(0, 8)}...`}
</Text>
<View className="flex-row items-center mt-1">
{editingMemberId === item.user_id ? (
<View className="flex-row items-center mr-4">
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'} mr-2`}>
Credits:
</Text>
<TextInput
className={`${isDarkMode ? 'bg-gray-600 border-gray-500 text-white' : 'bg-white border-gray-300 text-gray-800'} border rounded px-2 py-1 w-16 text-xs`}
value={newCreditLimit}
onChangeText={setNewCreditLimit}
keyboardType="numeric"
autoFocus
/>
<TouchableOpacity
onPress={updateMemberLimit}
className="ml-2 p-1"
disabled={updatingLimit}
>
<FontAwesome5 name="check" size={12} color={isDarkMode ? '#10B981' : '#059669'} />
</TouchableOpacity>
<TouchableOpacity
onPress={cancelEditingLimit}
className="ml-1 p-1"
disabled={updatingLimit}
>
<FontAwesome5 name="times" size={12} color={isDarkMode ? '#F87171' : '#EF4444'} />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
onPress={() => isAdmin && startEditingLimit(item.user_id, item.allocated_credits)}
className={`flex-row items-center mr-4 ${isAdmin ? 'opacity-100' : 'opacity-80'}`}
>
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Zugewiesene Credits: {item.allocated_credits}
</Text>
{isAdmin && (
<FontAwesome5
name="edit"
size={10}
color={isDarkMode ? '#93C5FD' : '#0055FF'}
style={{ marginLeft: 4 }}
/>
)}
</TouchableOpacity>
)}
<Text className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Genutzte Credits: {item.used_credits}
</Text>
</View>
</View>
{isAdmin && (
<View className="flex-row">
<TouchableOpacity
className={`${isDarkMode ? 'bg-blue-900' : 'bg-blue-100'} p-2 rounded-full mr-2`}
onPress={() => startEditingLimit(item.user_id, item.allocated_credits)}
>
<FontAwesome5 name="sliders-h" size={16} color={isDarkMode ? '#93C5FD' : '#0055FF'} />
</TouchableOpacity>
<TouchableOpacity
className={`${isDarkMode ? 'bg-red-900' : 'bg-red-100'} p-2 rounded-full`}
onPress={() => removeMember(item.user_id, item.first_name && item.last_name
? `${item.first_name} ${item.last_name}`
: `Benutzer ${item.user_id.substring(0, 8)}...`)}
>
<FontAwesome5 name="user-minus" size={16} color={isDarkMode ? '#F87171' : '#EF4444'} />
</TouchableOpacity>
</View>
)}
</View>
)}
/>
)}
</View>
</View>
);
}

View file

@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 15.0.15",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

View file

@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View file

@ -0,0 +1,10 @@
[build]
command = "npx expo export --platform web --clear"
publish = "dist"
environment = { NODE_VERSION = "18" }
# Umleitung für SPA-Routing
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View file

@ -0,0 +1,62 @@
{
"name": "@manacore/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web --port 19006",
"web:dev": "expo start --web --port 19006"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.0.5",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.81.1",
"expo": "^54.0.25",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.18",
"expo-dev-launcher": "^5.0.17",
"expo-linking": "~8.0.9",
"expo-router": "~6.0.15",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.9",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-universe": "^12.0.1",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.9.3"
},
"eslintConfig": {
"extends": "universe/native",
"root": true
},
"private": true
}

View file

@ -0,0 +1,10 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View file

@ -0,0 +1,21 @@
-- Funktion zum Finden eines Benutzers anhand seiner E-Mail-Adresse
CREATE OR REPLACE FUNCTION public.find_user_by_email(email_to_find TEXT)
RETURNS TABLE (
id UUID,
email TEXT
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT au.id, au.email::text
FROM auth.users au
WHERE au.email ILIKE email_to_find;
END;
$$;
-- Gewähre Zugriffsrechte für die Funktion
GRANT EXECUTE ON FUNCTION public.find_user_by_email(TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION public.find_user_by_email(TEXT) TO service_role;

View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~/*": [
"*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View file

@ -0,0 +1,121 @@
/**
* Hybrid storage solution for Supabase authentication
* Uses localStorage in browser environments and memory storage in SSR
* This avoids issues with AsyncStorage during server-side rendering
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
interface StorageData {
[key: string]: string;
}
class HybridStorageService {
private memoryStorage: StorageData = {};
private isAsyncStorageAvailable: boolean = false;
constructor() {
// Check if we're in an environment where AsyncStorage is available
this.checkAsyncStorageAvailability();
}
private async checkAsyncStorageAvailability(): Promise<void> {
try {
// Check if we're in a browser environment
if (typeof window === 'undefined') {
this.isAsyncStorageAvailable = false;
return;
}
// Try to access AsyncStorage
this.isAsyncStorageAvailable = typeof AsyncStorage !== 'undefined';
// If available, try to load any existing auth data into memory
if (this.isAsyncStorageAvailable) {
await this.syncFromAsyncStorage();
}
} catch (error) {
console.warn('AsyncStorage not available, falling back to memory storage');
this.isAsyncStorageAvailable = false;
}
}
private async syncFromAsyncStorage(): Promise<void> {
if (typeof window === 'undefined') {
// Skip AsyncStorage sync in SSR environment
return;
}
try {
// Get all keys that start with 'supabase.auth'
const allKeys = await AsyncStorage.getAllKeys();
const authKeys = allKeys.filter(key => key.startsWith('supabase.auth'));
if (authKeys.length > 0) {
const keyValuePairs = await AsyncStorage.multiGet(authKeys);
keyValuePairs.forEach(([key, value]) => {
if (value) {
this.memoryStorage[key] = value;
}
});
}
} catch (error) {
console.error('Error syncing from AsyncStorage:', error);
}
}
async getItem(key: string): Promise<string | null> {
// First check memory storage
const memoryValue = this.memoryStorage[key];
if (memoryValue) return memoryValue;
// If not in memory and AsyncStorage is available, try to get from there
if (this.isAsyncStorageAvailable && typeof window !== 'undefined') {
try {
const value = await AsyncStorage.getItem(key);
if (value) {
// Update memory cache
this.memoryStorage[key] = value;
return value;
}
} catch (error) {
console.error('Error reading from AsyncStorage:', error);
}
}
return null;
}
async setItem(key: string, value: string): Promise<void> {
// Always update memory storage
this.memoryStorage[key] = value;
// If AsyncStorage is available, also persist there
if (this.isAsyncStorageAvailable && typeof window !== 'undefined') {
try {
await AsyncStorage.setItem(key, value);
} catch (error) {
console.error('Error writing to AsyncStorage:', error);
}
}
}
async removeItem(key: string): Promise<void> {
// Remove from memory storage
delete this.memoryStorage[key];
// If AsyncStorage is available, also remove from there
if (this.isAsyncStorageAvailable && typeof window !== 'undefined') {
try {
await AsyncStorage.removeItem(key);
} catch (error) {
console.error('Error removing from AsyncStorage:', error);
}
}
}
}
// Create a singleton instance
const hybridStorage = new HybridStorageService();
export default hybridStorage;

View file

@ -0,0 +1,36 @@
import { createClient } from '@supabase/supabase-js';
import { Platform } from 'react-native';
import memoryStorage from './memoryStorage';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
// Überprüfen, ob die Umgebungsvariablen definiert sind
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Supabase URL oder Anon Key fehlen in den Umgebungsvariablen');
}
// Web-spezifische Konfiguration
const webConfig = Platform.OS === 'web' ? {
global: {
headers: {
'X-Client-Info': 'supabase-js-web',
},
},
// Disable realtime for web to avoid import issues
realtime: {
params: {
eventsPerSecond: 0,
},
},
} : {};
export const supabase = createClient(supabaseUrl || '', supabaseAnonKey || '', {
auth: {
storage: memoryStorage, // Verwende benutzerdefinierte memoryStorage-Lösung
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
...webConfig,
});

View file

@ -0,0 +1,108 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Definiere die möglichen Theme-Modi
export type ThemeMode = 'light' | 'dark' | 'system';
// Theme-Kontext-Interface
interface ThemeContextType {
themeMode: ThemeMode;
isDarkMode: boolean;
setThemeMode: (mode: ThemeMode) => void;
}
// Erstelle den Theme-Kontext
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Theme Provider-Komponente
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Hole das Systemthema
const systemColorScheme = useColorScheme();
// State für den Theme-Modus
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
// Berechne, ob der Dark Mode aktiv ist
const isDarkMode = themeMode === 'system'
? systemColorScheme === 'dark'
: themeMode === 'dark';
// Lade den gespeicherten Theme-Modus beim Start
useEffect(() => {
const loadThemeMode = async () => {
try {
const savedThemeMode = await AsyncStorage.getItem('themeMode');
if (savedThemeMode) {
setThemeModeState(savedThemeMode as ThemeMode);
}
} catch (error) {
console.error('Fehler beim Laden des Theme-Modus:', error);
}
};
loadThemeMode();
}, []);
// Funktion zum Ändern des Theme-Modus
const setThemeMode = async (mode: ThemeMode) => {
try {
await AsyncStorage.setItem('themeMode', mode);
setThemeModeState(mode);
} catch (error) {
console.error('Fehler beim Speichern des Theme-Modus:', error);
}
};
return (
<ThemeContext.Provider value={{ themeMode, isDarkMode, setThemeMode }}>
{children}
</ThemeContext.Provider>
);
};
// Hook zum Verwenden des Theme-Kontexts
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme muss innerhalb eines ThemeProviders verwendet werden');
}
return context;
};
// Farbpaletten für Light und Dark Mode
export const lightColors = {
background: '#FFFFFF',
backgroundSecondary: '#F5F5F5',
text: '#1F2937',
textSecondary: '#6B7280',
primary: '#0055FF',
primaryDark: '#0044CC',
border: '#E5E7EB',
error: '#EF4444',
success: '#10B981',
warning: '#F59E0B',
card: '#FFFFFF',
cardShadow: 'rgba(0, 0, 0, 0.1)',
};
export const darkColors = {
background: '#121212',
backgroundSecondary: '#1E1E1E',
text: '#F9FAFB',
textSecondary: '#9CA3AF',
primary: '#3B82F6',
primaryDark: '#2563EB',
border: '#374151',
error: '#F87171',
success: '#34D399',
warning: '#FBBF24',
card: '#1E1E1E',
cardShadow: 'rgba(0, 0, 0, 0.5)',
};
// Funktion zum Abrufen der aktuellen Farbpalette basierend auf dem Theme-Modus
export const useThemeColors = () => {
const { isDarkMode } = useTheme();
return isDarkMode ? darkColors : lightColors;
};