From 114fda056dc63d18058c31a991fc66d2bae41a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Fri, 17 Apr 2026 15:33:19 +0200 Subject: [PATCH] Hotfixes and CI/CD --- .gitea/workflows/deploy.yaml | 40 +++ Dockerfile | 16 + README.md | 10 + package.json | 3 +- public/index.html | 57 ++-- public/logo192.png | Bin 0 -> 4534 bytes public/logo512.png | Bin 0 -> 4534 bytes public/manifest.json | 17 +- public/service-worker.js | 47 +++ scripts/closeSessions.js | 51 +++ setup.js | 3 +- src/App.css | 26 ++ src/App.js | 135 +++++++- src/components/AdminRoute.js | 39 +++ src/context/AuthContext.js | 64 +++- src/index.js | 13 + src/pages/Admin.css | 431 ++++++++++++++++++++++++ src/pages/Admin.js | 315 +++++++++++++++++ src/pages/Sessions.js | 20 +- src/pages/Teams.css | 558 +++++++++++++++++++++++++++++++ src/pages/Teams.js | 350 +++++++++++++++++++ src/services/teamService.js | 424 +++++++++++++++++++++++ supabase/close_sessions_cron.sql | 48 +++ supabase/schema.sql | 192 +++++++++++ 24 files changed, 2806 insertions(+), 53 deletions(-) create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 Dockerfile create mode 100644 public/logo192.png create mode 100644 public/logo512.png create mode 100644 public/service-worker.js create mode 100644 scripts/closeSessions.js create mode 100644 src/components/AdminRoute.js create mode 100644 src/pages/Admin.css create mode 100644 src/pages/Admin.js create mode 100644 src/pages/Teams.css create mode 100644 src/pages/Teams.js create mode 100644 src/services/teamService.js create mode 100644 supabase/close_sessions_cron.sql create mode 100644 supabase/schema.sql diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..14f7215 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,40 @@ +name: Build and Deploy + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker image + run: docker build -t my-app . + + - name: Log in to Docker registry + run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Tag Docker image + run: docker tag my-app my-registry/my-app:${{ github.run_number }} + + - name: Push Docker image to registry + run: docker push my-registry/my-app:${{ github.run_number }} + + - name: Connect to remote host + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.REMOTE_HOST }} + username: ${{ secrets.REMOTE_USER }} + password: ${{ secrets.REMOTE_PASSWORD }} + + - name: Pull and run docker compose + run: | + docker pull + docker-compose up -d diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be55ecb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Builder stage +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +COPY . . +RUN node setup.js +RUN npm ci +RUN npm run build + +# Runner stage +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app/build ./build +RUN npm install -g serve +EXPOSE 32100 +CMD ["serve", "-s", "build", "-l", "32100"] \ No newline at end of file diff --git a/README.md b/README.md index b2af5b4..5239d1b 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,16 @@ grant usage on schema public to anon, authenticated; grant all on table timers to anon, authenticated; ``` +### Automatic Session Closure (Optional) + +To automatically close active sessions at midnight every day (without deleting history): + +1. Make sure the `pg_cron` extension is enabled in your Supabase project (it's usually enabled by default) +2. Run the SQL script in `supabase/close_sessions_cron.sql` in your Supabase SQL Editor +3. The cron job will automatically run daily at 00:00 to close any active sessions + +The function identifies sessions that are currently active (where `end_time` is NULL) and sets their `end_time` to midnight, calculating the duration accordingly. This preserves all session history while ensuring no session remains indefinitely open. + ## Session Management Features ### Creating New Sessions diff --git a/package.json b/package.json index 2354cbb..cebd4fa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "setup": "node setup.js" + "setup": "node setup.js", + "close-active-sessions": "node scripts/closeSessions.js" }, "eslintConfig": { "extends": [ diff --git a/public/index.html b/public/index.html index f3e7dee..fe61449 100644 --- a/public/index.html +++ b/public/index.html @@ -5,39 +5,38 @@ - - - + + + + + + + + + + + - - React App + + Time Tracker
- + + + diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..253ea5e7e08f83cb9cb8a7ef9bf96957cb79cbd5 GIT binary patch literal 4534 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 literal 0 HcmV?d00001 diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..253ea5e7e08f83cb9cb8a7ef9bf96957cb79cbd5 GIT binary patch literal 4534 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 literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json index 781de8b..dd134ea 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,7 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Time Tracker", + "name": "Ficosa Time Tracker App", + "description": "Track your work hours and manage your time efficiently", "icons": [ { "src": "favicon.ico", @@ -8,18 +9,20 @@ "type": "image/x-icon" }, { - "src": "LOGO_FICOSA.svg", - "type": "image/svg+xml", + "src": "logo192.png", + "type": "image/png", "sizes": "192x192" }, { - "src": "logo_ficosa.png", + "src": "logo512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": ".", "display": "standalone", + "orientation": "portrait", "theme_color": "#000000", - "background_color": "#ffffff" -} + "background_color": "#ffffff", + "prefer_related_applications": false +} \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..7f3be84 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,47 @@ +const CACHE_NAME = 'time-tracker-app-v1'; +const urlsToCache = [ + '/', + '/static/js/bundle.js', + '/static/css/main.css', + '/manifest.json', + '/favicon.ico', + '/logo_ficosa.png' +]; + +// Install event - cache essential assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('Opened cache'); + return cache.addAll(urlsToCache); + }) + ); +}); + +// Fetch event - serve cached content when offline +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request) + .then((response) => { + // Return cached version or fetch from network + return response || fetch(event.request); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); +}); \ No newline at end of file diff --git a/scripts/closeSessions.js b/scripts/closeSessions.js new file mode 100644 index 0000000..bcc22b5 --- /dev/null +++ b/scripts/closeSessions.js @@ -0,0 +1,51 @@ +// Script to manually close active sessions +// Usage: node scripts/closeSessions.js + +const { createClient } = require('@supabase/supabase-js'); + +// Load environment variables +require('dotenv').config(); + +// Supabase configuration +const supabaseUrl = process.env.REACT_APP_SUPABASE_URL; +const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + console.error('Missing Supabase credentials. Please check your .env file.'); + process.exit(1); +} + +// Create Supabase client +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function closeActiveSessions() { + try { + console.log('Closing active sessions...'); + + // Get current timestamp at midnight + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // Update all active sessions (where end_time is NULL) + const { data, error } = await supabase + .from('timers') + .update({ + end_time: midnight.toISOString(), + }) + .eq('end_time', null) + .lt('start_time', midnight.toISOString()) + .select(); + + if (error) { + console.error('Error closing sessions:', error); + return; + } + + console.log(`Successfully closed ${data.length} active sessions (history preserved).`); + } catch (error) { + console.error('Error:', error); + } +} + +// Run the function +closeActiveSessions(); \ No newline at end of file diff --git a/setup.js b/setup.js index 252034a..3e68325 100644 --- a/setup.js +++ b/setup.js @@ -48,4 +48,5 @@ console.log('\nFor Supabase setup:'); console.log('- Visit https://supabase.io to create an account'); console.log('- Create a new project'); console.log('- Find your project URL and anon key in the project settings'); -console.log('- Create the required tables (users and timers) in the SQL editor'); \ No newline at end of file +console.log('- Create the required tables (users and timers) in the SQL editor'); +console.log('- Optionally, set up automatic session closure (preserves history) by running the SQL in supabase/close_sessions_cron.sql'); \ No newline at end of file diff --git a/src/App.css b/src/App.css index e90c015..feda1d4 100644 --- a/src/App.css +++ b/src/App.css @@ -18,6 +18,19 @@ --surface-soft: color-mix(in srgb, var(--card-bg) 86%, transparent); --surface-strong: color-mix(in srgb, var(--input-bg) 90%, transparent); --surface-border-strong: color-mix(in srgb, var(--card-border) 72%, var(--accent) 28%); + + /* New theme variables for Admin and Teams pages */ + --text-primary: #0e2247; + --text-secondary: #5b7094; + --text-inverse: #ffffff; + --surface-primary: rgba(255, 255, 255, 0.82); + --surface-secondary: rgba(245, 249, 255, 0.6); + --border-color: rgba(193, 214, 244, 0.6); + --shadow-color: rgba(23, 56, 111, 0.1); + --accent-color: #1f7aff; + --error-color: #f04438; + --error-bg: rgba(240, 68, 56, 0.1); + --error-text: #991b1b; } [data-theme='dark'] { @@ -31,6 +44,19 @@ --card-border: rgba(72, 106, 153, 0.35); --shadow: 0 22px 56px rgba(1, 5, 14, 0.65); --input-bg: rgba(10, 18, 33, 0.92); + + /* Dark theme variables for Admin and Teams pages */ + --text-primary: #f3f7ff; + --text-secondary: #9db0cf; + --text-inverse: #070b14; + --surface-primary: rgba(15, 24, 41, 0.8); + --surface-secondary: rgba(10, 18, 33, 0.92); + --border-color: rgba(72, 106, 153, 0.35); + --shadow-color: rgba(1, 5, 14, 0.65); + --accent-color: #5aa0ff; + --error-color: #fca5a5; + --error-bg: rgba(220, 38, 38, 0.15); + --error-text: #fecaca; } * { diff --git a/src/App.js b/src/App.js index 82a977f..1b2f2f0 100644 --- a/src/App.js +++ b/src/App.js @@ -6,6 +6,9 @@ import Dashboard from './pages/Dashboard'; import Sessions from './pages/Sessions'; import Calendar from './pages/Calendar'; import Profile from './pages/Profile'; +import Admin from './pages/Admin'; +import Teams from './pages/Teams'; +import AdminRoute from './components/AdminRoute'; import './App.css'; // Protected route component @@ -21,7 +24,7 @@ const PublicRoute = ({ children }) => { }; const AppNavBar = () => { - const { isAuthenticated, logout, user } = useAuth(); + const { isAuthenticated, logout, user, isAdmin } = useAuth(); const location = useLocation(); const navigate = useNavigate(); @@ -55,6 +58,14 @@ const AppNavBar = () => { Calendar + + Teams + + {isAdmin && ( + + Admin + + )} )} {isAuthenticated && ( @@ -88,6 +99,16 @@ const AppNavBar = () => { 📅 Calendar + + 👥 + Teams + + {isAdmin && ( + + ⚙️ + Admin + + )} {profileAvatar ? ( Profile @@ -155,15 +176,125 @@ function AppContent() { } /> + + + + } + /> + + + + } + /> ); } function App() { + const [deferredPrompt, setDeferredPrompt] = React.useState(null); + const [showInstallPrompt, setShowInstallPrompt] = React.useState(false); + + React.useEffect(() => { + const handleBeforeInstallPrompt = (e) => { + // Prevent the mini-infobar from appearing on mobile + e.preventDefault(); + // Stash the event so it can be triggered later + setDeferredPrompt(e); + // Show the install prompt button + setShowInstallPrompt(true); + }; + + // Listen for the beforeinstallprompt event + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, []); + + const handleInstallClick = () => { + if (!deferredPrompt) return; + + // Show the install prompt + deferredPrompt.prompt(); + + // Wait for the user to respond to the prompt + deferredPrompt.userChoice.then((choiceResult) => { + if (choiceResult.outcome === 'accepted') { + console.log('User accepted the install prompt'); + } else { + console.log('User dismissed the install prompt'); + } + // Clear the deferred prompt + setDeferredPrompt(null); + setShowInstallPrompt(false); + }); + }; + return ( - + <> + {/* Installation prompt */} + {showInstallPrompt && ( +
+

Install Time Tracker App

+

Add this app to your home screen for faster access and offline functionality.

+
+ + +
+
+ )} + +
); } diff --git a/src/components/AdminRoute.js b/src/components/AdminRoute.js new file mode 100644 index 0000000..900d513 --- /dev/null +++ b/src/components/AdminRoute.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const AdminRoute = ({ children }) => { + const { isAuthenticated, isAdmin, loading } = useAuth(); + + // Show loading indicator while checking auth state + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + // Not authenticated - redirect to login + if (!isAuthenticated) { + return ; + } + + // Not admin - show permission denied + if (!isAdmin) { + return ( +
+
+

Access Denied

+

You do not have permission to access this page.

+

Only administrators can access the admin panel.

+
+
+ ); + } + + // Admin - render children + return children; +}; + +export default AdminRoute; diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index 26ef40a..a08ab1f 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -1,5 +1,6 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; import sessionService from '../services/sessionService'; +import teamService from '../services/teamService'; import { supabase } from '../services/supabaseClient'; const AuthContext = createContext(); @@ -14,6 +15,12 @@ export const AuthProvider = ({ children }) => { sessions: [] }); const [loading, setLoading] = useState(true); + const [userRole, setUserRole] = useState('user'); + const [userTeams, setUserTeams] = useState([]); + const [userProfile, setUserProfile] = useState(null); + + const isAdmin = userRole === 'admin'; + const isManager = userRole === 'manager' || userRole === 'admin'; const createTicker = () => setInterval(() => {}, 1000); const isPersistedSession = (id) => typeof id === 'string'; @@ -23,6 +30,28 @@ export const AuthProvider = ({ children }) => { end: pause.end ? new Date(pause.end).toISOString() : null })); + const loadUserProfile = async (userId) => { + try { + const profile = await teamService.getUserProfile(userId); + setUserProfile(profile); + setUserRole(profile?.globalRole || 'user'); + } catch (error) { + console.error('Error loading user profile:', error); + setUserRole('user'); + setUserProfile(null); + } + }; + + const loadUserTeams = async (userId) => { + try { + const teams = await teamService.getUserTeams(userId); + setUserTeams(teams); + } catch (error) { + console.error('Error loading user teams:', error); + setUserTeams([]); + } + }; + // Check for existing session on app load useEffect(() => { const checkSession = async () => { @@ -49,6 +78,8 @@ export const AuthProvider = ({ children }) => { setIsAuthenticated(true); setUser(session.user); await loadSessions(session.user.id); + await loadUserProfile(session.user.id); + await loadUserTeams(session.user.id); } else if (event === 'SIGNED_OUT') { setIsAuthenticated(false); setUser(null); @@ -58,6 +89,9 @@ export const AuthProvider = ({ children }) => { pausedTimer: null, sessions: [] }); + setUserRole('user'); + setUserTeams([]); + setUserProfile(null); } } ); @@ -240,6 +274,8 @@ export const AuthProvider = ({ children }) => { if (error) throw error; if (data?.user) { setUser(data.user); + await loadUserProfile(data.user.id); + await loadUserTeams(data.user.id); } return { success: true, user: data?.user ?? null }; } catch (error) { @@ -264,6 +300,8 @@ export const AuthProvider = ({ children }) => { // Load sessions from database await loadSessions(userData.id); + await loadUserProfile(userData.id); + await loadUserTeams(userData.id); return { success: true }; } catch (error) { console.error('Login error:', error); @@ -285,6 +323,8 @@ export const AuthProvider = ({ children }) => { sessions: [] }); + await loadUserProfile(userData.id); + await loadUserTeams(userData.id); return { success: true }; } catch (error) { console.error('Registration error:', error); @@ -303,6 +343,9 @@ export const AuthProvider = ({ children }) => { pausedTimer: null, sessions: [] }); + setUserRole('user'); + setUserTeams([]); + setUserProfile(null); } catch (error) { console.error('Logout error:', error); } @@ -413,7 +456,11 @@ export const AuthProvider = ({ children }) => { start_time: new Date(updatedCurrent.startTime).toISOString(), end_time: null, pauses: toIsoPauses(updatedCurrent.pauses) - }).catch((error) => console.error('Failed to save pause state:', error)); + }).catch((error) => { + console.error('Failed to save pause state:', error); + // Show user-friendly error message + alert('Could not save session: ' + (error.message || 'Connection timeout. Please check your network and try again.')); + }); } return { @@ -459,7 +506,11 @@ export const AuthProvider = ({ children }) => { start_time: new Date(updatedCurrent.startTime).toISOString(), end_time: null, pauses: toIsoPauses(updatedCurrent.pauses) - }).catch((error) => console.error('Failed to save resume state:', error)); + }).catch((error) => { + console.error('Failed to save resume state:', error); + // Show user-friendly error message + alert('Could not save session: ' + (error.message || 'Connection timeout. Please check your network and try again.')); + }); } return { @@ -524,7 +575,14 @@ export const AuthProvider = ({ children }) => { updateCurrentSessionEntry, refreshUser, calculateWorkTime, - formatDuration + formatDuration, + userRole, + userTeams, + userProfile, + isAdmin, + isManager, + loadUserProfile, + loadUserTeams }; return ( diff --git a/src/index.js b/src/index.js index d563c0f..c962028 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,19 @@ import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +// Register service worker for PWA functionality +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js') + .then((registration) => { + console.log('Service Worker registered: ', registration); + }) + .catch((error) => { + console.log('Service Worker registration failed: ', error); + }); + }); +} + const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/src/pages/Admin.css b/src/pages/Admin.css new file mode 100644 index 0000000..89b1b34 --- /dev/null +++ b/src/pages/Admin.css @@ -0,0 +1,431 @@ +/* Admin Page Styles */ + +.admin-page { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + min-height: 100vh; +} + +.admin-header { + text-align: center; + margin-bottom: 1rem; +} + +.admin-header h1 { + font-size: 2.5rem; + margin: 0; + color: var(--text-primary); +} + +.admin-header p { + margin: 0.5rem 0 0 0; + color: var(--text-secondary); + font-size: 1.1rem; +} + +.admin-sections { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +/* Admin Section Base Styles */ +.admin-section { + background: var(--surface-primary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 2px 8px var(--shadow-color); + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.admin-section:hover { + box-shadow: 0 4px 12px var(--shadow-color); +} + +.admin-section h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 0.75rem; +} + +/* Section Header */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.section-header h2 { + margin: 0; + flex: 1; +} + +.btn-refresh { + padding: 0.6rem 1.2rem; + background-color: var(--accent-color); + color: var(--text-inverse); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.btn-refresh:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-refresh:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Error Messages */ +.error-message { + padding: 1rem; + margin-bottom: 1rem; + background-color: var(--error-bg); + color: var(--error-text); + border-radius: 6px; + border-left: 4px solid var(--error-color); +} + +.permission-denied { + display: flex; + align-items: center; + justify-content: center; + min-height: 50vh; + background: var(--surface-primary); + border-radius: 12px; + padding: 2rem; + text-align: center; +} + +.permission-denied-content h1 { + font-size: 2rem; + color: var(--error-color); + margin-bottom: 1rem; +} + +.permission-denied-content p { + color: var(--text-secondary); + font-size: 1.1rem; + margin: 0.5rem 0; +} + +/* ============ USER MANAGEMENT ============ */ +.user-list { + overflow-x: auto; + margin-top: 1rem; +} + +.users-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.users-table thead { + background-color: var(--surface-secondary); + border-bottom: 2px solid var(--border-color); +} + +.users-table th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--text-primary); +} + +.users-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.users-table tbody tr:hover { + background-color: var(--surface-secondary); +} + +/* Role Badge */ +.role-badge { + display: inline-block; + padding: 0.4rem 0.8rem; + border-radius: 20px; + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; +} + +.role-admin { + background-color: rgba(239, 68, 68, 0.2); + color: #dc2626; +} + +.role-manager { + background-color: rgba(59, 130, 246, 0.2); + color: #2563eb; +} + +.role-user { + background-color: rgba(107, 114, 128, 0.2); + color: #4b5563; +} + +/* ============ PERMISSION MANAGEMENT ============ */ +.permission-controls { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.control-group label { + font-weight: 600; + color: var(--text-primary); +} + +.control-group select { + padding: 0.75rem; + background-color: var(--surface-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.control-group select:hover, +.control-group select:focus { + border-color: var(--accent-color); + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.role-buttons { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-top: 1rem; +} + +.btn-role { + flex: 1; + min-width: 150px; + padding: 0.75rem 1.5rem; + background-color: var(--accent-color); + color: var(--text-inverse); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.btn-role:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-role:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============ TIME REPORTS ============ */ +.report-filters { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.report-filters input { + padding: 0.6rem; + background-color: var(--surface-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; +} + +.report-filters input:hover, +.report-filters input:focus { + border-color: var(--accent-color); + outline: none; +} + +.report-filters span { + color: var(--text-secondary); + font-weight: 500; +} + +.reports-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.team-report-card { + background-color: var(--surface-secondary); + border-radius: 8px; + padding: 1.5rem; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.team-report-card:hover { + box-shadow: 0 4px 12px var(--shadow-color); + transform: translateY(-2px); +} + +.team-report-card h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1.2rem; +} + +.report-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.report-table thead { + background-color: var(--surface-primary); + border-bottom: 2px solid var(--border-color); +} + +.report-table th { + padding: 0.75rem; + text-align: left; + font-weight: 600; + color: var(--text-primary); +} + +.report-table td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.report-table tbody tr:hover { + background-color: var(--surface-primary); +} + +.team-summary { + padding: 1rem; + background-color: var(--surface-primary); + border-radius: 6px; + color: var(--text-primary); + font-weight: 600; + text-align: right; +} + +/* ============ RESPONSIVE ============ */ +@media (max-width: 768px) { + .admin-page { + padding: 1rem; + gap: 1.5rem; + } + + .admin-section { + padding: 1.5rem; + } + + .admin-header h1 { + font-size: 1.75rem; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .report-filters { + flex-direction: column; + align-items: stretch; + } + + .report-filters input, + .btn-refresh { + width: 100%; + } + + .reports-container { + grid-template-columns: 1fr; + } + + .users-table { + font-size: 0.85rem; + } + + .users-table th, + .users-table td { + padding: 0.5rem; + } + + .role-buttons { + flex-direction: column; + } + + .btn-role { + width: 100%; + } +} + +@media (max-width: 480px) { + .admin-page { + padding: 0.75rem; + } + + .admin-header h1 { + font-size: 1.5rem; + } + + .admin-header p { + font-size: 0.95rem; + } + + .section-header { + gap: 0.75rem; + } + + .users-table { + font-size: 0.75rem; + } + + .users-table th, + .users-table td { + padding: 0.4rem; + } + + .role-badge { + padding: 0.3rem 0.6rem; + font-size: 0.75rem; + } +} diff --git a/src/pages/Admin.js b/src/pages/Admin.js new file mode 100644 index 0000000..b69fd0d --- /dev/null +++ b/src/pages/Admin.js @@ -0,0 +1,315 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../context/AuthContext'; +import teamService from '../services/teamService'; +import './Admin.css'; + +const Admin = () => { + const { user, userTeams, loadUserTeams } = useAuth(); + + // User Management State + const [users, setUsers] = useState([]); + const [userLoading, setUserLoading] = useState(false); + const [userError, setUserError] = useState(''); + + // Permission Management State + const [selectedUser, setSelectedUser] = useState(null); + const [selectedTeam, setSelectedTeam] = useState(null); + const [permissionLoading, setPermissionLoading] = useState(false); + const [permissionError, setPermissionError] = useState(''); + + // Time Reports State + const [reports, setReports] = useState([]); + const [reportLoading, setReportLoading] = useState(false); + const [reportError, setReportError] = useState(''); + const [reportStartDate, setReportStartDate] = useState(getDateString(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000))); + const [reportEndDate, setReportEndDate] = useState(getDateString(new Date())); + + // Load all users on mount + useEffect(() => { + fetchUsers(); + }, []); + + // Load initial reports + useEffect(() => { + loadTeamReports(); + }, []); + + // Helper function to format dates + function getDateString(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + // ============ USER MANAGEMENT ============ + const fetchUsers = async () => { + setUserLoading(true); + setUserError(''); + try { + const allUsers = await teamService.getAllUsers(); + setUsers(allUsers); + } catch (error) { + console.error('Error fetching users:', error); + setUserError('Failed to load users'); + } finally { + setUserLoading(false); + } + }; + + // ============ PERMISSION MANAGEMENT ============ + const loadTeamMembers = async (teamId) => { + try { + const members = await teamService.getTeamMembers(teamId); + console.log('Team members:', members); + } catch (error) { + console.error('Error loading team members:', error); + } + }; + + const updateMemberRole = async (teamId, userId, newRole) => { + if (!selectedTeam || !selectedUser) { + setPermissionError('Please select a team and user first'); + return; + } + + setPermissionLoading(true); + setPermissionError(''); + try { + await teamService.updateMemberRole(teamId, userId, newRole); + setPermissionError(''); + // Reload teams to reflect changes + await loadUserTeams(user.id); + alert('Permission updated successfully'); + setSelectedUser(null); + setSelectedTeam(null); + } catch (error) { + console.error('Error updating member role:', error); + setPermissionError('Failed to update permissions'); + } finally { + setPermissionLoading(false); + } + }; + + // ============ TIME REPORTS ============ + const loadTeamReports = async () => { + setReportLoading(true); + setReportError(''); + try { + const startDate = new Date(reportStartDate); + const endDate = new Date(reportEndDate); + endDate.setHours(23, 59, 59, 999); + + const reportsData = await teamService.getAllTeamReports(startDate, endDate); + setReports(reportsData); + } catch (error) { + console.error('Error loading reports:', error); + setReportError('Failed to load time reports'); + } finally { + setReportLoading(false); + } + }; + + const handleReportDateChange = () => { + loadTeamReports(); + }; + + // ============ RENDER ============ + return ( +
+
+

Admin Panel

+

Manage users, permissions, and view team reports

+
+ +
+ {/* USER MANAGEMENT SECTION */} +
+
+

User Management

+ +
+ + {userError &&
{userError}
} + +
+ {users.length === 0 ? ( +

No users found

+ ) : ( + + + + + + + + + + + + {users.map((usr) => ( + + + + + + + + ))} + +
EmailFull NameCompanyGlobal RoleCreated At
{usr.email || 'N/A'}{usr.fullName || 'N/A'}{usr.company || 'N/A'} + + {usr.globalRole} + + {new Date(usr.createdAt).toLocaleDateString()}
+ )} +
+
+ + {/* PERMISSION MANAGEMENT SECTION */} +
+

Permission Management

+ + {permissionError &&
{permissionError}
} + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ + {/* TIME REPORTS SECTION */} +
+
+

Team Time Reports

+
+ setReportStartDate(e.target.value)} + /> + to + setReportEndDate(e.target.value)} + /> + +
+
+ + {reportError &&
{reportError}
} + +
+ {reports.length === 0 ? ( +

No time reports available for the selected date range

+ ) : ( + reports.map((teamReport) => ( +
+

{teamReport.teamName}

+ + + + + + + + + + + {teamReport.userReports.map((userReport, idx) => ( + + + + + + + ))} + +
User EmailRoleTotal SessionsTotal Time
{userReport.userEmail} + + {userReport.memberRole} + + {userReport.totalSessions}{formatDuration(userReport.totalDurationMs)}
+
+ Team Total Time: {formatDuration(teamReport.totalDurationMs)} +
+
+ )) + )} +
+
+
+
+ ); +}; + +// Helper function to format duration in milliseconds +function formatDuration(ms) { + if (!ms || ms === 0) return '0h 0m'; + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + if (hours === 0) return `${minutes}m`; + return `${hours}h ${minutes}m`; +} + +export default Admin; diff --git a/src/pages/Sessions.js b/src/pages/Sessions.js index c9dd7a0..ef50666 100644 --- a/src/pages/Sessions.js +++ b/src/pages/Sessions.js @@ -315,14 +315,14 @@ const Sessions = () => { const result = await withTimeout( sessionService.createSession(newSessionData), - 15000, - 'Request timed out while creating session. Check DB/network and try again.' + 30000, + 'Request timed out while creating session. Please check your network connection and try again.' ); if (result.success) { await withTimeout( refreshSessions(), - 15000, - 'Request timed out while refreshing sessions after create.' + 30000, + 'Request timed out while refreshing sessions. Please check your network connection and try again.' ); if (editForm.makeActive && result.data) { makeSessionActive(toActiveSessionShape(result.data)); @@ -349,8 +349,8 @@ const Sessions = () => { end_time: null, pauses: normalizedPausesForDb }), - 15000, - 'Request timed out while updating current session.' + 30000, + 'Request timed out while updating current session. Please check your network connection and try again.' ); await withTimeout( refreshSessions(), @@ -379,14 +379,14 @@ const Sessions = () => { const result = await withTimeout( sessionService.updateSession(editForm.id, updateData), - 15000, - 'Request timed out while updating session.' + 30000, + 'Request timed out while updating session. Please check your network connection and try again.' ); if (result.success) { await withTimeout( refreshSessions(), - 15000, - 'Request timed out while refreshing sessions after update.' + 30000, + 'Request timed out while refreshing sessions. Please check your network connection and try again.' ); if (editForm.makeActive && result.data) { makeSessionActive(toActiveSessionShape(result.data)); diff --git a/src/pages/Teams.css b/src/pages/Teams.css new file mode 100644 index 0000000..2f1be15 --- /dev/null +++ b/src/pages/Teams.css @@ -0,0 +1,558 @@ +/* Teams Page Styles */ + +.teams-page { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + min-height: 100vh; +} + +.teams-header { + text-align: center; + margin-bottom: 1rem; +} + +.teams-header h1 { + font-size: 2.5rem; + margin: 0; + color: var(--text-primary); +} + +.teams-header p { + margin: 0.5rem 0 0 0; + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* Main Layout */ +.teams-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +/* Teams List Section */ +.teams-list-section { + display: flex; + flex-direction: column; + gap: 1.5rem; + background: var(--surface-primary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 2px 8px var(--shadow-color); + border: 1px solid var(--border-color); +} + +.teams-list-section h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 0.75rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; + gap: 1rem; +} + +.section-header h2 { + margin-bottom: 0; + flex: 1; +} + +/* Buttons */ +.btn-primary, +.btn-remove { + padding: 0.6rem 1.2rem; + background-color: var(--accent-color); + color: var(--text-inverse); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.btn-primary:hover, +.btn-remove:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-danger { + padding: 0.6rem 1.2rem; + background-color: #ef4444; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.btn-danger:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +.btn-remove { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; +} + +.btn-remove:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +/* Error Message */ +.error-message { + padding: 1rem; + background-color: var(--error-bg); + color: var(--error-text); + border-radius: 6px; + border-left: 4px solid var(--error-color); + margin-bottom: 1rem; +} + +/* Create Team Form */ +.create-team-form { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + background-color: var(--surface-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.create-team-form input, +.create-team-form textarea { + padding: 0.75rem; + background-color: var(--surface-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-family: inherit; + font-size: 1rem; + transition: all 0.2s ease; +} + +.create-team-form input:focus, +.create-team-form textarea:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.create-team-form button { + padding: 0.75rem; + background-color: var(--accent-color); + color: var(--text-inverse); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s ease; +} + +.create-team-form button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +/* Teams Grid */ +.teams-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +.team-card { + padding: 1.5rem; + background-color: var(--surface-secondary); + border: 2px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.team-card:hover { + border-color: var(--accent-color); + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--shadow-color); +} + +.team-card.active { + border-color: var(--accent-color); + background-color: var(--surface-primary); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.team-card h3 { + margin: 0; + color: var(--text-primary); + font-size: 1.1rem; +} + +.team-description { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.4; +} + +.team-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.team-role { + font-size: 0.85rem; + font-weight: 600; + color: var(--accent-color); + text-transform: uppercase; +} + +.all-teams-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid var(--border-color); +} + +.all-teams-section h2 { + margin: 0 0 1rem 0; + color: var(--text-primary); + font-size: 1.2rem; +} + +/* Loading State */ +.loading { + padding: 2rem; + text-align: center; + color: var(--text-secondary); + font-size: 1rem; +} + +/* Empty State */ +.empty-state { + padding: 2rem; + text-align: center; + color: var(--text-secondary); + background-color: var(--surface-secondary); + border-radius: 8px; + border: 1px dashed var(--border-color); +} + +.empty-state p { + margin: 0.5rem 0; +} + +/* Team Details Section */ +.team-details-section { + display: flex; + flex-direction: column; + gap: 2rem; + background: var(--surface-primary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 2px 8px var(--shadow-color); + border: 1px solid var(--border-color); + height: fit-content; +} + +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin: 0 0 1rem 0; +} + +.details-header h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 0.75rem; + flex: 1; +} + +.team-description-box { + padding: 1rem; + background-color: var(--surface-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + color: var(--text-secondary); + line-height: 1.6; +} + +/* Add Member Form */ +.add-member-form { + padding: 1.5rem; + background-color: var(--surface-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.add-member-form h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.form-row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.form-row input, +.form-row select, +.form-row button { + flex: 1; + min-width: 120px; + padding: 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color); + font-size: 0.95rem; + font-family: inherit; +} + +.form-row input, +.form-row select { + background-color: var(--surface-primary); + color: var(--text-primary); +} + +.form-row input:focus, +.form-row select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-row button { + background-color: var(--accent-color); + color: var(--text-inverse); + border: none; + cursor: pointer; + font-weight: 600; + transition: all 0.2s ease; +} + +.form-row button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +/* Members Section */ +.members-section { + margin-top: 1rem; +} + +.members-section h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.members-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.member-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background-color: var(--surface-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.member-item:hover { + background-color: var(--surface-secondary); + border-color: var(--accent-color); +} + +.member-info { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.member-avatar { + width: 40px; + height: 40px; + background-color: var(--accent-color); + color: var(--text-inverse); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1rem; + flex-shrink: 0; +} + +.member-details { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.member-email { + margin: 0; + color: var(--text-primary); + font-weight: 500; +} + +.role-badge { + display: inline-block; + width: fit-content; + padding: 0.3rem 0.6rem; + border-radius: 4px; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; +} + +.role-admin { + background-color: rgba(239, 68, 68, 0.2); + color: #dc2626; +} + +.role-manager { + background-color: rgba(59, 130, 246, 0.2); + color: #2563eb; +} + +.role-user { + background-color: rgba(107, 114, 128, 0.2); + color: #4b5563; +} + +/* ============ RESPONSIVE ============ */ +@media (max-width: 1024px) { + .teams-layout { + grid-template-columns: 1fr; + } + + .team-details-section { + height: auto; + } +} + +@media (max-width: 768px) { + .teams-page { + padding: 1rem; + gap: 1.5rem; + } + + .teams-header h1 { + font-size: 1.75rem; + } + + .teams-list-section, + .team-details-section { + padding: 1.5rem; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + } + + .btn-primary { + width: 100%; + } + + .form-row { + flex-direction: column; + } + + .form-row input, + .form-row select, + .form-row button { + width: 100%; + } + + .details-header { + flex-direction: column; + align-items: flex-start; + } + + .details-header h2 { + width: 100%; + } + + .btn-danger { + width: 100%; + } + + .teams-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} + +@media (max-width: 480px) { + .teams-page { + padding: 0.75rem; + } + + .teams-header h1 { + font-size: 1.5rem; + } + + .teams-list-section, + .team-details-section { + padding: 1rem; + } + + .teams-grid { + grid-template-columns: 1fr; + } + + .team-card { + padding: 1rem; + } + + .team-card h3 { + font-size: 1rem; + } + + .member-item { + flex-direction: column; + align-items: flex-start; + } + + .member-item .btn-remove { + width: 100%; + margin-top: 0.75rem; + } +} diff --git a/src/pages/Teams.js b/src/pages/Teams.js new file mode 100644 index 0000000..360fb10 --- /dev/null +++ b/src/pages/Teams.js @@ -0,0 +1,350 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../context/AuthContext'; +import teamService from '../services/teamService'; +import './Teams.css'; + +const Teams = () => { + const { user, userTeams, loadUserTeams, isAdmin, isManager } = useAuth(); + + // State for teams + const [teams, setTeams] = useState([]); + const [allTeams, setAllTeams] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // State for creating/editing teams + const [showCreateForm, setShowCreateForm] = useState(false); + const [newTeamName, setNewTeamName] = useState(''); + const [newTeamDescription, setNewTeamDescription] = useState(''); + const [creatingTeam, setCreatingTeam] = useState(false); + + // State for team details modal + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [teamMembers, setTeamMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); + + // State for adding members + const [newMemberEmail, setNewMemberEmail] = useState(''); + const [newMemberRole, setNewMemberRole] = useState('user'); + const [addingMember, setAddingMember] = useState(false); + + // Load user's teams on mount + useEffect(() => { + loadTeams(); + }, []); + + // Load all teams if user is admin + useEffect(() => { + if (isAdmin) { + loadAllTeams(); + } + }, [isAdmin]); + + // Load team members when selected team changes + useEffect(() => { + if (selectedTeamId) { + loadTeamMembers(selectedTeamId); + } + }, [selectedTeamId]); + + const loadTeams = async () => { + setLoading(true); + setError(''); + try { + await loadUserTeams(user.id); + setTeams(userTeams); + } catch (err) { + console.error('Error loading teams:', err); + setError('Failed to load teams'); + } finally { + setLoading(false); + } + }; + + const loadAllTeams = async () => { + try { + const allTeamsData = await teamService.getTeams(); + setAllTeams(allTeamsData); + } catch (err) { + console.error('Error loading all teams:', err); + } + }; + + const loadTeamMembers = async (teamId) => { + setMembersLoading(true); + try { + const members = await teamService.getTeamMembers(teamId); + setTeamMembers(members); + } catch (err) { + console.error('Error loading team members:', err); + setTeamMembers([]); + } finally { + setMembersLoading(false); + } + }; + + const handleCreateTeam = async (e) => { + e.preventDefault(); + if (!newTeamName.trim()) { + setError('Team name is required'); + return; + } + + setCreatingTeam(true); + setError(''); + try { + const newTeam = await teamService.createTeam(newTeamName, newTeamDescription); + setNewTeamName(''); + setNewTeamDescription(''); + setShowCreateForm(false); + setError(''); + await loadTeams(); + if (isAdmin) await loadAllTeams(); + alert('Team created successfully!'); + } catch (err) { + console.error('Error creating team:', err); + setError('Failed to create team'); + } finally { + setCreatingTeam(false); + } + }; + + const handleAddMember = async (e) => { + e.preventDefault(); + if (!newMemberEmail.trim() || !selectedTeamId) { + setError('Please enter an email and select a team'); + return; + } + + setAddingMember(true); + setError(''); + try { + // Find user by email - first get all users and find match + const allUsers = await teamService.getAllUsers(); + const foundUser = allUsers.find((u) => u.email === newMemberEmail.trim()); + + if (!foundUser) { + setError('User not found'); + setAddingMember(false); + return; + } + + await teamService.addMember(selectedTeamId, foundUser.id, newMemberRole); + setNewMemberEmail(''); + setNewMemberRole('user'); + setError(''); + await loadTeamMembers(selectedTeamId); + alert('Member added successfully!'); + } catch (err) { + console.error('Error adding member:', err); + setError('Failed to add member'); + } finally { + setAddingMember(false); + } + }; + + const handleRemoveMember = async (teamId, userId) => { + if (!window.confirm('Are you sure you want to remove this member?')) return; + + try { + await teamService.removeMember(teamId, userId); + setError(''); + await loadTeamMembers(teamId); + alert('Member removed successfully!'); + } catch (err) { + console.error('Error removing member:', err); + setError('Failed to remove member'); + } + }; + + const handleDeleteTeam = async (teamId) => { + if (!window.confirm('Are you sure you want to delete this team? This action cannot be undone.')) { + return; + } + + try { + await teamService.deleteTeam(teamId); + setError(''); + setSelectedTeamId(null); + await loadTeams(); + if (isAdmin) await loadAllTeams(); + alert('Team deleted successfully!'); + } catch (err) { + console.error('Error deleting team:', err); + setError('Failed to delete team'); + } + }; + + const selectedTeam = [...teams, ...allTeams].find((t) => t.id === selectedTeamId); + const canManageTeam = isManager && (selectedTeam?.createdBy === user.id || isAdmin); + + return ( +
+
+

Teams & Groups

+

Manage your team memberships and collaboration groups

+
+ + {error &&
{error}
} + +
+ {/* Teams List */} +
+
+

My Teams

+ {(isManager || isAdmin) && ( + + )} +
+ + {showCreateForm && (isManager || isAdmin) && ( +
+ setNewTeamName(e.target.value)} + required + /> +