Hotfixes and CI/CD
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m51s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m51s
This commit is contained in:
26
src/App.css
26
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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
135
src/App.js
135
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 = () => {
|
||||
<Link to="/calendar" className={`nav-link ${isActive('/calendar') ? 'active' : ''}`}>
|
||||
Calendar
|
||||
</Link>
|
||||
<Link to="/teams" className={`nav-link ${isActive('/teams') ? 'active' : ''}`}>
|
||||
Teams
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className={`nav-link ${isActive('/admin') ? 'active' : ''}`}>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
@@ -88,6 +99,16 @@ const AppNavBar = () => {
|
||||
<span className="mobile-webapp-icon">📅</span>
|
||||
<span className="mobile-webapp-label">Calendar</span>
|
||||
</Link>
|
||||
<Link to="/teams" className={`mobile-webapp-link ${isActive('/teams') ? 'active' : ''}`}>
|
||||
<span className="mobile-webapp-icon">👥</span>
|
||||
<span className="mobile-webapp-label">Teams</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className={`mobile-webapp-link ${isActive('/admin') ? 'active' : ''}`}>
|
||||
<span className="mobile-webapp-icon">⚙️</span>
|
||||
<span className="mobile-webapp-label">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/profile" className={`mobile-webapp-link ${isActive('/profile') ? 'active' : ''}`}>
|
||||
{profileAvatar ? (
|
||||
<img src={profileAvatar} alt="Profile" className="mobile-webapp-avatar" />
|
||||
@@ -155,15 +176,125 @@ function AppContent() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teams"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Teams />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Admin />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
<>
|
||||
{/* Installation prompt */}
|
||||
{showInstallPrompt && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: '#000',
|
||||
color: '#fff',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
zIndex: '1000',
|
||||
width: '90%',
|
||||
maxWidth: '400px'
|
||||
}}>
|
||||
<p style={{ margin: '0', fontWeight: 'bold' }}>Install Time Tracker App</p>
|
||||
<p style={{ margin: '5px 0', fontSize: '14px' }}>Add this app to your home screen for faster access and offline functionality.</p>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInstallPrompt(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
backgroundColor: '#666',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AppContent />
|
||||
</>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
39
src/components/AdminRoute.js
Normal file
39
src/components/AdminRoute.js
Normal file
@@ -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 (
|
||||
<div className="permission-denied">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - redirect to login
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Not admin - show permission denied
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="permission-denied">
|
||||
<div className="permission-denied-content">
|
||||
<h1>Access Denied</h1>
|
||||
<p>You do not have permission to access this page.</p>
|
||||
<p>Only administrators can access the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin - render children
|
||||
return children;
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
||||
@@ -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 (
|
||||
|
||||
13
src/index.js
13
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(
|
||||
<React.StrictMode>
|
||||
|
||||
431
src/pages/Admin.css
Normal file
431
src/pages/Admin.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
315
src/pages/Admin.js
Normal file
315
src/pages/Admin.js
Normal file
@@ -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 (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<p>Manage users, permissions, and view team reports</p>
|
||||
</header>
|
||||
|
||||
<div className="admin-sections">
|
||||
{/* USER MANAGEMENT SECTION */}
|
||||
<section className="admin-section">
|
||||
<div className="section-header">
|
||||
<h2>User Management</h2>
|
||||
<button className="btn-refresh" onClick={fetchUsers} disabled={userLoading}>
|
||||
{userLoading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{userError && <div className="error-message">{userError}</div>}
|
||||
|
||||
<div className="user-list">
|
||||
{users.length === 0 ? (
|
||||
<p>No users found</p>
|
||||
) : (
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Full Name</th>
|
||||
<th>Company</th>
|
||||
<th>Global Role</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((usr) => (
|
||||
<tr key={usr.id}>
|
||||
<td>{usr.email || 'N/A'}</td>
|
||||
<td>{usr.fullName || 'N/A'}</td>
|
||||
<td>{usr.company || 'N/A'}</td>
|
||||
<td>
|
||||
<span className={`role-badge role-${usr.globalRole}`}>
|
||||
{usr.globalRole}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(usr.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PERMISSION MANAGEMENT SECTION */}
|
||||
<section className="admin-section">
|
||||
<h2>Permission Management</h2>
|
||||
|
||||
{permissionError && <div className="error-message">{permissionError}</div>}
|
||||
|
||||
<div className="permission-controls">
|
||||
<div className="control-group">
|
||||
<label>Select User:</label>
|
||||
<select
|
||||
value={selectedUser || ''}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
>
|
||||
<option value="">Choose a user...</option>
|
||||
{users.map((usr) => (
|
||||
<option key={usr.id} value={usr.id}>
|
||||
{usr.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Select Team:</label>
|
||||
<select
|
||||
value={selectedTeam || ''}
|
||||
onChange={(e) => setSelectedTeam(e.target.value)}
|
||||
>
|
||||
<option value="">Choose a team...</option>
|
||||
{userTeams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="role-buttons">
|
||||
<button
|
||||
className="btn-role"
|
||||
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'admin')}
|
||||
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||
>
|
||||
Set as Admin
|
||||
</button>
|
||||
<button
|
||||
className="btn-role"
|
||||
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'manager')}
|
||||
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||
>
|
||||
Set as Manager
|
||||
</button>
|
||||
<button
|
||||
className="btn-role"
|
||||
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'user')}
|
||||
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||
>
|
||||
Set as User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TIME REPORTS SECTION */}
|
||||
<section className="admin-section">
|
||||
<div className="section-header">
|
||||
<h2>Team Time Reports</h2>
|
||||
<div className="report-filters">
|
||||
<input
|
||||
type="date"
|
||||
value={reportStartDate}
|
||||
onChange={(e) => setReportStartDate(e.target.value)}
|
||||
/>
|
||||
<span>to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={reportEndDate}
|
||||
onChange={(e) => setReportEndDate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn-refresh"
|
||||
onClick={handleReportDateChange}
|
||||
disabled={reportLoading}
|
||||
>
|
||||
{reportLoading ? 'Loading...' : 'Load Reports'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reportError && <div className="error-message">{reportError}</div>}
|
||||
|
||||
<div className="reports-container">
|
||||
{reports.length === 0 ? (
|
||||
<p>No time reports available for the selected date range</p>
|
||||
) : (
|
||||
reports.map((teamReport) => (
|
||||
<div key={teamReport.teamId} className="team-report-card">
|
||||
<h3>{teamReport.teamName}</h3>
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User Email</th>
|
||||
<th>Role</th>
|
||||
<th>Total Sessions</th>
|
||||
<th>Total Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teamReport.userReports.map((userReport, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{userReport.userEmail}</td>
|
||||
<td>
|
||||
<span className={`role-badge role-${userReport.memberRole}`}>
|
||||
{userReport.memberRole}
|
||||
</span>
|
||||
</td>
|
||||
<td>{userReport.totalSessions}</td>
|
||||
<td>{formatDuration(userReport.totalDurationMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="team-summary">
|
||||
<strong>Team Total Time:</strong> {formatDuration(teamReport.totalDurationMs)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -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));
|
||||
|
||||
558
src/pages/Teams.css
Normal file
558
src/pages/Teams.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
350
src/pages/Teams.js
Normal file
350
src/pages/Teams.js
Normal file
@@ -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 (
|
||||
<div className="teams-page">
|
||||
<header className="teams-header">
|
||||
<h1>Teams & Groups</h1>
|
||||
<p>Manage your team memberships and collaboration groups</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="teams-layout">
|
||||
{/* Teams List */}
|
||||
<div className="teams-list-section">
|
||||
<div className="section-header">
|
||||
<h2>My Teams</h2>
|
||||
{(isManager || isAdmin) && (
|
||||
<button className="btn-primary" onClick={() => setShowCreateForm(!showCreateForm)}>
|
||||
{showCreateForm ? 'Cancel' : '+ New Team'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateForm && (isManager || isAdmin) && (
|
||||
<form onSubmit={handleCreateTeam} className="create-team-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Team name"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)"
|
||||
value={newTeamDescription}
|
||||
onChange={(e) => setNewTeamDescription(e.target.value)}
|
||||
rows="3"
|
||||
/>
|
||||
<button type="submit" disabled={creatingTeam}>
|
||||
{creatingTeam ? 'Creating...' : 'Create Team'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading teams...</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>You are not a member of any teams yet.</p>
|
||||
{isManager && <p>Create a team or ask an admin to add you to one.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="teams-grid">
|
||||
{teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className={`team-card ${selectedTeamId === team.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTeamId(team.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<h3>{team.name}</h3>
|
||||
<p className="team-description">{team.description || 'No description'}</p>
|
||||
<div className="team-meta">
|
||||
<span className="team-role">{team.memberRole}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && allTeams.length > 0 && (
|
||||
<div className="all-teams-section">
|
||||
<h2>All Teams (Admin View)</h2>
|
||||
<div className="teams-grid">
|
||||
{allTeams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className={`team-card ${selectedTeamId === team.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTeamId(team.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<h3>{team.name}</h3>
|
||||
<p className="team-description">{team.description || 'No description'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Details */}
|
||||
{selectedTeamId && selectedTeam && (
|
||||
<div className="team-details-section">
|
||||
<div className="details-header">
|
||||
<h2>{selectedTeam.name}</h2>
|
||||
{canManageTeam && (
|
||||
<button className="btn-danger" onClick={() => handleDeleteTeam(selectedTeamId)}>
|
||||
Delete Team
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="team-description-box">
|
||||
<p>{selectedTeam.description || 'No description provided'}</p>
|
||||
</div>
|
||||
|
||||
{/* Add Member Form */}
|
||||
{canManageTeam && (
|
||||
<form onSubmit={handleAddMember} className="add-member-form">
|
||||
<h3>Add Member</h3>
|
||||
<div className="form-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Member email"
|
||||
value={newMemberEmail}
|
||||
onChange={(e) => setNewMemberEmail(e.target.value)}
|
||||
/>
|
||||
<select value={newMemberRole} onChange={(e) => setNewMemberRole(e.target.value)}>
|
||||
<option value="user">User</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button type="submit" disabled={addingMember}>
|
||||
{addingMember ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="members-section">
|
||||
<h3>Team Members ({teamMembers.length})</h3>
|
||||
{membersLoading ? (
|
||||
<div className="loading">Loading members...</div>
|
||||
) : teamMembers.length === 0 ? (
|
||||
<div className="empty-state">No members in this team</div>
|
||||
) : (
|
||||
<div className="members-list">
|
||||
{teamMembers.map((member) => (
|
||||
<div key={member.userId} className="member-item">
|
||||
<div className="member-info">
|
||||
<div className="member-avatar">
|
||||
{(member.userEmail || member.userId).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="member-details">
|
||||
<p className="member-email">{member.userEmail}</p>
|
||||
<span className={`role-badge role-${member.role}`}>{member.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManageTeam && member.userId !== user.id && (
|
||||
<button
|
||||
className="btn-remove"
|
||||
onClick={() => handleRemoveMember(selectedTeamId, member.userId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Teams;
|
||||
424
src/services/teamService.js
Normal file
424
src/services/teamService.js
Normal file
@@ -0,0 +1,424 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
class TeamService {
|
||||
// ─── Team CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
async createTeam(name, description = '') {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.insert({
|
||||
name,
|
||||
description,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Auto-add the creator as team admin
|
||||
await this.addMember(data.id, user.id, 'admin');
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdBy: data.created_by,
|
||||
createdAt: data.created_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating team:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateTeam(teamId, name, description) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.update({ name, description })
|
||||
.eq('id', teamId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdBy: data.created_by,
|
||||
createdAt: data.created_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating team:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTeam(teamId) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('teams')
|
||||
.delete()
|
||||
.eq('id', teamId);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting team:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTeams() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map(record => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
createdBy: record.created_by,
|
||||
createdAt: record.created_at
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching teams:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamById(teamId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.select('*')
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdBy: data.created_by,
|
||||
createdAt: data.created_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching team:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Team Member Management ──────────────────────────────────────────
|
||||
|
||||
async addMember(teamId, userId, role = 'user') {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
user_id: userId,
|
||||
role
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
teamId: data.team_id,
|
||||
userId: data.user_id,
|
||||
role: data.role,
|
||||
joinedAt: data.joined_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeMember(teamId, userId) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('team_members')
|
||||
.delete()
|
||||
.eq('team_id', teamId)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error removing team member:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateMemberRole(teamId, userId, role) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.update({ role })
|
||||
.eq('team_id', teamId)
|
||||
.eq('user_id', userId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
teamId: data.team_id,
|
||||
userId: data.user_id,
|
||||
role: data.role,
|
||||
joinedAt: data.joined_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating member role:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.select('id, user_id, role, joined_at, user_profiles(full_name, avatar_url)')
|
||||
.eq('team_id', teamId)
|
||||
.order('joined_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map(record => ({
|
||||
id: record.id,
|
||||
teamId: teamId,
|
||||
userId: record.user_id,
|
||||
role: record.role,
|
||||
joinedAt: record.joined_at,
|
||||
fullName: record.user_profiles?.full_name || '',
|
||||
avatarUrl: record.user_profiles?.avatar_url || ''
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching team members:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserTeams(userId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.select('team_id, role, joined_at, teams(id, name, description, created_at)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map(record => ({
|
||||
teamId: record.team_id,
|
||||
role: record.role,
|
||||
joinedAt: record.joined_at,
|
||||
team: {
|
||||
id: record.teams.id,
|
||||
name: record.teams.name,
|
||||
description: record.teams.description,
|
||||
createdAt: record.teams.created_at
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching user teams:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User Profile Queries ────────────────────────────────────────────
|
||||
|
||||
async getUserProfile(userId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
fullName: data.full_name,
|
||||
phone: data.phone,
|
||||
company: data.company,
|
||||
avatarUrl: data.avatar_url,
|
||||
globalRole: data.global_role,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserProfile(userId, profileData) {
|
||||
try {
|
||||
const updateFields = {};
|
||||
if (profileData.fullName !== undefined) updateFields.full_name = profileData.fullName;
|
||||
if (profileData.phone !== undefined) updateFields.phone = profileData.phone;
|
||||
if (profileData.company !== undefined) updateFields.company = profileData.company;
|
||||
if (profileData.avatarUrl !== undefined) updateFields.avatar_url = profileData.avatarUrl;
|
||||
if (profileData.globalRole !== undefined) updateFields.global_role = profileData.globalRole;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update(updateFields)
|
||||
.eq('id', userId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
fullName: data.full_name,
|
||||
phone: data.phone,
|
||||
company: data.company,
|
||||
avatarUrl: data.avatar_url,
|
||||
globalRole: data.global_role,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUsers() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map(record => ({
|
||||
id: record.id,
|
||||
fullName: record.full_name,
|
||||
phone: record.phone,
|
||||
company: record.company,
|
||||
avatarUrl: record.avatar_url,
|
||||
globalRole: record.global_role,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching all users:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Team Time Reports ───────────────────────────────────────────────
|
||||
|
||||
async getTeamTimeReport(teamId, startDate, endDate) {
|
||||
try {
|
||||
// Get all members of the team
|
||||
const members = await this.getTeamMembers(teamId);
|
||||
const memberIds = members.map(m => m.userId);
|
||||
|
||||
if (memberIds.length === 0) {
|
||||
return { teamId, members: [], totalDurationMs: 0 };
|
||||
}
|
||||
|
||||
// Build date range query for timers
|
||||
let query = supabase
|
||||
.from('timers')
|
||||
.select('user_id, start_time, end_time, duration_ms')
|
||||
.in('user_id', memberIds);
|
||||
|
||||
if (startDate) {
|
||||
query = query.gte('start_time', new Date(startDate).toISOString());
|
||||
}
|
||||
if (endDate) {
|
||||
query = query.lte('end_time', new Date(endDate).toISOString());
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Aggregate time by user
|
||||
const userTotals = {};
|
||||
data.forEach(record => {
|
||||
const uid = record.user_id;
|
||||
if (!userTotals[uid]) {
|
||||
userTotals[uid] = { userId: uid, totalDurationMs: 0, sessionCount: 0 };
|
||||
}
|
||||
userTotals[uid].totalDurationMs += record.duration_ms || 0;
|
||||
userTotals[uid].sessionCount += 1;
|
||||
});
|
||||
|
||||
// Enrich with member info
|
||||
const memberReports = members.map(member => {
|
||||
const totals = userTotals[member.userId] || { totalDurationMs: 0, sessionCount: 0 };
|
||||
return {
|
||||
userId: member.userId,
|
||||
fullName: member.fullName,
|
||||
avatarUrl: member.avatarUrl,
|
||||
role: member.role,
|
||||
totalDurationMs: totals.totalDurationMs,
|
||||
sessionCount: totals.sessionCount
|
||||
};
|
||||
});
|
||||
|
||||
const totalDurationMs = memberReports.reduce((sum, r) => sum + r.totalDurationMs, 0);
|
||||
|
||||
return {
|
||||
teamId,
|
||||
members: memberReports,
|
||||
totalDurationMs
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching team time report:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllTeamReports(startDate, endDate) {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
// Get all teams the user can see
|
||||
const teams = await this.getTeams();
|
||||
const reports = [];
|
||||
|
||||
for (const team of teams) {
|
||||
const report = await this.getTeamTimeReport(team.id, startDate, endDate);
|
||||
reports.push({
|
||||
...report,
|
||||
teamName: team.name,
|
||||
teamDescription: team.description
|
||||
});
|
||||
}
|
||||
|
||||
return reports;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all team reports:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamService = new TeamService();
|
||||
export default teamService;
|
||||
Reference in New Issue
Block a user