mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 06:17:43 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
3
apps/manacore/apps/mobile/.env.example
Normal file
3
apps/manacore/apps/mobile/.env.example
Normal 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
27
apps/manacore/apps/mobile/.gitignore
vendored
Normal 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
|
||||
2
apps/manacore/apps/mobile/app-env.d.ts
vendored
Normal file
2
apps/manacore/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
60
apps/manacore/apps/mobile/app.json
Normal file
60
apps/manacore/apps/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
apps/manacore/apps/mobile/app/(drawer)/(tabs)/_layout.tsx
Normal file
29
apps/manacore/apps/mobile/app/(drawer)/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/manacore/apps/mobile/app/(drawer)/(tabs)/index.tsx
Normal file
15
apps/manacore/apps/mobile/app/(drawer)/(tabs)/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
apps/manacore/apps/mobile/app/(drawer)/(tabs)/two.tsx
Normal file
15
apps/manacore/apps/mobile/app/(drawer)/(tabs)/two.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/manacore/apps/mobile/app/(drawer)/_layout.tsx
Normal file
114
apps/manacore/apps/mobile/app/(drawer)/_layout.tsx
Normal 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;
|
||||
222
apps/manacore/apps/mobile/app/(drawer)/apps.tsx
Normal file
222
apps/manacore/apps/mobile/app/(drawer)/apps.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
199
apps/manacore/apps/mobile/app/(drawer)/get-mana.tsx
Normal file
199
apps/manacore/apps/mobile/app/(drawer)/get-mana.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
apps/manacore/apps/mobile/app/(drawer)/index.tsx
Normal file
132
apps/manacore/apps/mobile/app/(drawer)/index.tsx
Normal 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
|
||||
497
apps/manacore/apps/mobile/app/(drawer)/organizations/[id].tsx
Normal file
497
apps/manacore/apps/mobile/app/(drawer)/organizations/[id].tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
apps/manacore/apps/mobile/app/(drawer)/send-mana.tsx
Normal file
34
apps/manacore/apps/mobile/app/(drawer)/send-mana.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
407
apps/manacore/apps/mobile/app/(drawer)/settings.tsx
Normal file
407
apps/manacore/apps/mobile/app/(drawer)/settings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
308
apps/manacore/apps/mobile/app/(drawer)/teams/[id].tsx
Normal file
308
apps/manacore/apps/mobile/app/(drawer)/teams/[id].tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
apps/manacore/apps/mobile/app/(drawer)/teams/index.tsx
Normal file
76
apps/manacore/apps/mobile/app/(drawer)/teams/index.tsx
Normal 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
|
||||
46
apps/manacore/apps/mobile/app/+html.tsx
Normal file
46
apps/manacore/apps/mobile/app/+html.tsx
Normal 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;
|
||||
}
|
||||
}`;
|
||||
24
apps/manacore/apps/mobile/app/+not-found.tsx
Normal file
24
apps/manacore/apps/mobile/app/+not-found.tsx
Normal 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]`,
|
||||
};
|
||||
78
apps/manacore/apps/mobile/app/_layout.tsx
Normal file
78
apps/manacore/apps/mobile/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/manacore/apps/mobile/app/auth/_layout.tsx
Normal file
9
apps/manacore/apps/mobile/app/auth/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
apps/manacore/apps/mobile/app/auth/reset-password.tsx
Normal file
292
apps/manacore/apps/mobile/app/auth/reset-password.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
34
apps/manacore/apps/mobile/app/login.tsx
Normal file
34
apps/manacore/apps/mobile/app/login.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
13
apps/manacore/apps/mobile/app/modal.tsx
Normal file
13
apps/manacore/apps/mobile/app/modal.tsx
Normal 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'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/manacore/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/manacore/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/manacore/apps/mobile/assets/favicon.png
Normal file
BIN
apps/manacore/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/manacore/apps/mobile/assets/icon.png
Normal file
BIN
apps/manacore/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/manacore/apps/mobile/assets/splash.png
Normal file
BIN
apps/manacore/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
12
apps/manacore/apps/mobile/babel.config.js
Normal file
12
apps/manacore/apps/mobile/babel.config.js
Normal 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,
|
||||
};
|
||||
};
|
||||
40
apps/manacore/apps/mobile/cesconfig.json
Normal file
40
apps/manacore/apps/mobile/cesconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
245
apps/manacore/apps/mobile/components/Account.tsx
Normal file
245
apps/manacore/apps/mobile/components/Account.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
329
apps/manacore/apps/mobile/components/Auth.tsx
Normal file
329
apps/manacore/apps/mobile/components/Auth.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
22
apps/manacore/apps/mobile/components/Button.tsx
Normal file
22
apps/manacore/apps/mobile/components/Button.tsx
Normal 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',
|
||||
};
|
||||
12
apps/manacore/apps/mobile/components/Container.tsx
Normal file
12
apps/manacore/apps/mobile/components/Container.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
243
apps/manacore/apps/mobile/components/CreateOrganization.tsx
Normal file
243
apps/manacore/apps/mobile/components/CreateOrganization.tsx
Normal 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
|
||||
326
apps/manacore/apps/mobile/components/CreateTeam.tsx
Normal file
326
apps/manacore/apps/mobile/components/CreateTeam.tsx
Normal 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
|
||||
165
apps/manacore/apps/mobile/components/DashboardStats.tsx
Normal file
165
apps/manacore/apps/mobile/components/DashboardStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/manacore/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
apps/manacore/apps/mobile/components/EditScreenInfo.tsx
Normal 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`,
|
||||
};
|
||||
31
apps/manacore/apps/mobile/components/HeaderButton.tsx
Normal file
31
apps/manacore/apps/mobile/components/HeaderButton.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
304
apps/manacore/apps/mobile/components/OrganizationList.tsx
Normal file
304
apps/manacore/apps/mobile/components/OrganizationList.tsx
Normal 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;
|
||||
25
apps/manacore/apps/mobile/components/ScreenContent.tsx
Normal file
25
apps/manacore/apps/mobile/components/ScreenContent.tsx
Normal 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`,
|
||||
};
|
||||
593
apps/manacore/apps/mobile/components/SendMana.tsx
Normal file
593
apps/manacore/apps/mobile/components/SendMana.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
15
apps/manacore/apps/mobile/components/TabBarIcon.tsx
Normal file
15
apps/manacore/apps/mobile/components/TabBarIcon.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
338
apps/manacore/apps/mobile/components/TeamList.tsx
Normal file
338
apps/manacore/apps/mobile/components/TeamList.tsx
Normal 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
|
||||
664
apps/manacore/apps/mobile/components/TeamMembers.tsx
Normal file
664
apps/manacore/apps/mobile/components/TeamMembers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/manacore/apps/mobile/eas.json
Normal file
21
apps/manacore/apps/mobile/eas.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
3
apps/manacore/apps/mobile/global.css
Normal file
3
apps/manacore/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
6
apps/manacore/apps/mobile/metro.config.js
Normal file
6
apps/manacore/apps/mobile/metro.config.js
Normal 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' });
|
||||
3
apps/manacore/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/manacore/apps/mobile/nativewind-env.d.ts
vendored
Normal 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.
|
||||
10
apps/manacore/apps/mobile/netlify.toml
Normal file
10
apps/manacore/apps/mobile/netlify.toml
Normal 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
|
||||
62
apps/manacore/apps/mobile/package.json
Normal file
62
apps/manacore/apps/mobile/package.json
Normal 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
|
||||
}
|
||||
10
apps/manacore/apps/mobile/prettier.config.js
Normal file
10
apps/manacore/apps/mobile/prettier.config.js
Normal 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'],
|
||||
};
|
||||
|
|
@ -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;
|
||||
10
apps/manacore/apps/mobile/tailwind.config.js
Normal file
10
apps/manacore/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
20
apps/manacore/apps/mobile/tsconfig.json
Normal file
20
apps/manacore/apps/mobile/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
121
apps/manacore/apps/mobile/utils/memoryStorage.ts
Normal file
121
apps/manacore/apps/mobile/utils/memoryStorage.ts
Normal 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;
|
||||
36
apps/manacore/apps/mobile/utils/supabase.ts
Normal file
36
apps/manacore/apps/mobile/utils/supabase.ts
Normal 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,
|
||||
});
|
||||
108
apps/manacore/apps/mobile/utils/themeContext.tsx
Normal file
108
apps/manacore/apps/mobile/utils/themeContext.tsx
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue