Hotfixes and CI/CD
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m51s

This commit is contained in:
Antoni Nuñez Romeu
2026-04-17 15:33:19 +02:00
parent b3f7d6bf98
commit 114fda056d
24 changed files with 2806 additions and 53 deletions

View File

@@ -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

16
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -75,6 +75,16 @@ grant usage on schema public to anon, authenticated;
grant all on table timers 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 ## Session Management Features
### Creating New Sessions ### Creating New Sessions

View File

@@ -22,7 +22,8 @@
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"setup": "node setup.js" "setup": "node setup.js",
"close-active-sessions": "node scripts/closeSessions.js"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [

View File

@@ -5,39 +5,38 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta name="description" content="Time Tracker App for Ficosa employees" />
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo_ficosa.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will <!-- PWA Meta Tags -->
work correctly both with client-side routing and a non-root public URL. <meta name="mobile-web-app-capable" content="yes">
Learn how to configure a non-root public URL by running `npm run build`. <meta name="apple-mobile-web-app-title" content="Time Tracker">
--> <meta name="apple-mobile-web-app-capable" content="yes">
<title>React App</title> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- Manifest and Service Worker -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Time Tracker</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file. <!-- Service Worker Registration -->
The build step will place the bundled scripts into the <body> tag. <script>
if ('serviceWorker' in navigator) {
To begin the development, run `npm start` or `yarn start`. window.addEventListener('load', () => {
To create a production bundle, use `npm run build` or `yarn build`. navigator.serviceWorker.register('%PUBLIC_URL%/service-worker.js')
--> .then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
</body> </body>
</html> </html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,6 +1,7 @@
{ {
"short_name": "React App", "short_name": "Time Tracker",
"name": "Create React App Sample", "name": "Ficosa Time Tracker App",
"description": "Track your work hours and manage your time efficiently",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
@@ -8,18 +9,20 @@
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "LOGO_FICOSA.svg", "src": "logo192.png",
"type": "image/svg+xml", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "logo_ficosa.png", "src": "logo512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#ffffff" "background_color": "#ffffff",
"prefer_related_applications": false
} }

47
public/service-worker.js Normal file
View File

@@ -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);
}
})
);
})
);
});

51
scripts/closeSessions.js Normal file
View File

@@ -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();

View File

@@ -49,3 +49,4 @@ console.log('- Visit https://supabase.io to create an account');
console.log('- Create a new project'); console.log('- Create a new project');
console.log('- Find your project URL and anon key in the project settings'); 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'); 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');

View File

@@ -18,6 +18,19 @@
--surface-soft: color-mix(in srgb, var(--card-bg) 86%, transparent); --surface-soft: color-mix(in srgb, var(--card-bg) 86%, transparent);
--surface-strong: color-mix(in srgb, var(--input-bg) 90%, 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%); --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'] { [data-theme='dark'] {
@@ -31,6 +44,19 @@
--card-border: rgba(72, 106, 153, 0.35); --card-border: rgba(72, 106, 153, 0.35);
--shadow: 0 22px 56px rgba(1, 5, 14, 0.65); --shadow: 0 22px 56px rgba(1, 5, 14, 0.65);
--input-bg: rgba(10, 18, 33, 0.92); --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;
} }
* { * {

View File

@@ -6,6 +6,9 @@ import Dashboard from './pages/Dashboard';
import Sessions from './pages/Sessions'; import Sessions from './pages/Sessions';
import Calendar from './pages/Calendar'; import Calendar from './pages/Calendar';
import Profile from './pages/Profile'; import Profile from './pages/Profile';
import Admin from './pages/Admin';
import Teams from './pages/Teams';
import AdminRoute from './components/AdminRoute';
import './App.css'; import './App.css';
// Protected route component // Protected route component
@@ -21,7 +24,7 @@ const PublicRoute = ({ children }) => {
}; };
const AppNavBar = () => { const AppNavBar = () => {
const { isAuthenticated, logout, user } = useAuth(); const { isAuthenticated, logout, user, isAdmin } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -55,6 +58,14 @@ const AppNavBar = () => {
<Link to="/calendar" className={`nav-link ${isActive('/calendar') ? 'active' : ''}`}> <Link to="/calendar" className={`nav-link ${isActive('/calendar') ? 'active' : ''}`}>
Calendar Calendar
</Link> </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 && ( {isAuthenticated && (
@@ -88,6 +99,16 @@ const AppNavBar = () => {
<span className="mobile-webapp-icon">📅</span> <span className="mobile-webapp-icon">📅</span>
<span className="mobile-webapp-label">Calendar</span> <span className="mobile-webapp-label">Calendar</span>
</Link> </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' : ''}`}> <Link to="/profile" className={`mobile-webapp-link ${isActive('/profile') ? 'active' : ''}`}>
{profileAvatar ? ( {profileAvatar ? (
<img src={profileAvatar} alt="Profile" className="mobile-webapp-avatar" /> <img src={profileAvatar} alt="Profile" className="mobile-webapp-avatar" />
@@ -155,15 +176,125 @@ function AppContent() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/teams"
element={
<ProtectedRoute>
<Teams />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<AdminRoute>
<Admin />
</AdminRoute>
}
/>
</Routes> </Routes>
</Router> </Router>
); );
} }
function App() { 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 ( return (
<AuthProvider> <AuthProvider>
<>
{/* 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 /> <AppContent />
</>
</AuthProvider> </AuthProvider>
); );
} }

View 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;

View File

@@ -1,5 +1,6 @@
import React, { createContext, useState, useContext, useEffect } from 'react'; import React, { createContext, useState, useContext, useEffect } from 'react';
import sessionService from '../services/sessionService'; import sessionService from '../services/sessionService';
import teamService from '../services/teamService';
import { supabase } from '../services/supabaseClient'; import { supabase } from '../services/supabaseClient';
const AuthContext = createContext(); const AuthContext = createContext();
@@ -14,6 +15,12 @@ export const AuthProvider = ({ children }) => {
sessions: [] sessions: []
}); });
const [loading, setLoading] = useState(true); 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 createTicker = () => setInterval(() => {}, 1000);
const isPersistedSession = (id) => typeof id === 'string'; const isPersistedSession = (id) => typeof id === 'string';
@@ -23,6 +30,28 @@ export const AuthProvider = ({ children }) => {
end: pause.end ? new Date(pause.end).toISOString() : null 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 // Check for existing session on app load
useEffect(() => { useEffect(() => {
const checkSession = async () => { const checkSession = async () => {
@@ -49,6 +78,8 @@ export const AuthProvider = ({ children }) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setUser(session.user); setUser(session.user);
await loadSessions(session.user.id); await loadSessions(session.user.id);
await loadUserProfile(session.user.id);
await loadUserTeams(session.user.id);
} else if (event === 'SIGNED_OUT') { } else if (event === 'SIGNED_OUT') {
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
@@ -58,6 +89,9 @@ export const AuthProvider = ({ children }) => {
pausedTimer: null, pausedTimer: null,
sessions: [] sessions: []
}); });
setUserRole('user');
setUserTeams([]);
setUserProfile(null);
} }
} }
); );
@@ -240,6 +274,8 @@ export const AuthProvider = ({ children }) => {
if (error) throw error; if (error) throw error;
if (data?.user) { if (data?.user) {
setUser(data.user); setUser(data.user);
await loadUserProfile(data.user.id);
await loadUserTeams(data.user.id);
} }
return { success: true, user: data?.user ?? null }; return { success: true, user: data?.user ?? null };
} catch (error) { } catch (error) {
@@ -264,6 +300,8 @@ export const AuthProvider = ({ children }) => {
// Load sessions from database // Load sessions from database
await loadSessions(userData.id); await loadSessions(userData.id);
await loadUserProfile(userData.id);
await loadUserTeams(userData.id);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -285,6 +323,8 @@ export const AuthProvider = ({ children }) => {
sessions: [] sessions: []
}); });
await loadUserProfile(userData.id);
await loadUserTeams(userData.id);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error);
@@ -303,6 +343,9 @@ export const AuthProvider = ({ children }) => {
pausedTimer: null, pausedTimer: null,
sessions: [] sessions: []
}); });
setUserRole('user');
setUserTeams([]);
setUserProfile(null);
} catch (error) { } catch (error) {
console.error('Logout error:', error); console.error('Logout error:', error);
} }
@@ -413,7 +456,11 @@ export const AuthProvider = ({ children }) => {
start_time: new Date(updatedCurrent.startTime).toISOString(), start_time: new Date(updatedCurrent.startTime).toISOString(),
end_time: null, end_time: null,
pauses: toIsoPauses(updatedCurrent.pauses) 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 { return {
@@ -459,7 +506,11 @@ export const AuthProvider = ({ children }) => {
start_time: new Date(updatedCurrent.startTime).toISOString(), start_time: new Date(updatedCurrent.startTime).toISOString(),
end_time: null, end_time: null,
pauses: toIsoPauses(updatedCurrent.pauses) 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 { return {
@@ -524,7 +575,14 @@ export const AuthProvider = ({ children }) => {
updateCurrentSessionEntry, updateCurrentSessionEntry,
refreshUser, refreshUser,
calculateWorkTime, calculateWorkTime,
formatDuration formatDuration,
userRole,
userTeams,
userProfile,
isAdmin,
isManager,
loadUserProfile,
loadUserTeams
}; };
return ( return (

View File

@@ -4,6 +4,19 @@ import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; 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')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>

431
src/pages/Admin.css Normal file
View 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
View 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;

View File

@@ -315,14 +315,14 @@ const Sessions = () => {
const result = await withTimeout( const result = await withTimeout(
sessionService.createSession(newSessionData), sessionService.createSession(newSessionData),
15000, 30000,
'Request timed out while creating session. Check DB/network and try again.' 'Request timed out while creating session. Please check your network connection and try again.'
); );
if (result.success) { if (result.success) {
await withTimeout( await withTimeout(
refreshSessions(), refreshSessions(),
15000, 30000,
'Request timed out while refreshing sessions after create.' 'Request timed out while refreshing sessions. Please check your network connection and try again.'
); );
if (editForm.makeActive && result.data) { if (editForm.makeActive && result.data) {
makeSessionActive(toActiveSessionShape(result.data)); makeSessionActive(toActiveSessionShape(result.data));
@@ -349,8 +349,8 @@ const Sessions = () => {
end_time: null, end_time: null,
pauses: normalizedPausesForDb pauses: normalizedPausesForDb
}), }),
15000, 30000,
'Request timed out while updating current session.' 'Request timed out while updating current session. Please check your network connection and try again.'
); );
await withTimeout( await withTimeout(
refreshSessions(), refreshSessions(),
@@ -379,14 +379,14 @@ const Sessions = () => {
const result = await withTimeout( const result = await withTimeout(
sessionService.updateSession(editForm.id, updateData), sessionService.updateSession(editForm.id, updateData),
15000, 30000,
'Request timed out while updating session.' 'Request timed out while updating session. Please check your network connection and try again.'
); );
if (result.success) { if (result.success) {
await withTimeout( await withTimeout(
refreshSessions(), refreshSessions(),
15000, 30000,
'Request timed out while refreshing sessions after update.' 'Request timed out while refreshing sessions. Please check your network connection and try again.'
); );
if (editForm.makeActive && result.data) { if (editForm.makeActive && result.data) {
makeSessionActive(toActiveSessionShape(result.data)); makeSessionActive(toActiveSessionShape(result.data));

558
src/pages/Teams.css Normal file
View 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
View 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
View 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;

View File

@@ -0,0 +1,48 @@
-- ============================================================
-- Time Tracker App - Automatic Session Closure at Midnight
-- Run this SQL in your Supabase SQL Editor
-- ============================================================
-- Check if pg_cron extension is available
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Function to close active sessions at midnight
-- This will only close sessions that are currently running (end_time IS NULL)
-- and will preserve all session history
CREATE OR REPLACE FUNCTION close_active_sessions_at_midnight()
RETURNS void AS $$
DECLARE
closed_count INTEGER;
BEGIN
-- Update all active sessions (where end_time is NULL)
-- Set end_time to today at 00:00:00 (midnight)
-- This closes only currently active sessions, preserving all history
UPDATE timers
SET end_time = DATE_TRUNC('day', NOW()),
duration_ms = EXTRACT(EPOCH FROM (DATE_TRUNC('day', NOW()) - start_time)) * 1000
WHERE end_time IS NULL
AND start_time < DATE_TRUNC('day', NOW());
-- Get the count of closed sessions for the notice
GET DIAGNOSTICS closed_count = ROW_COUNT;
RAISE NOTICE 'Closed % active sessions at midnight (history preserved)', closed_count;
END;
$$ LANGUAGE plpgsql;
-- Schedule the function to run daily at midnight (00:00)
-- This will run in the timezone of your Supabase project
SELECT cron.schedule(
'close-active-sessions-at-midnight', -- job name
'0 0 * * *', -- cron expression (at 00:00 every day)
$$SELECT close_active_sessions_at_midnight()$$
);
-- To manually test the function, you can run:
-- SELECT close_active_sessions_at_midnight();
-- To view all cron jobs:
-- SELECT * FROM cron.job;
-- To unschedule this job:
-- SELECT cron.unschedule('close-active-sessions-at-midnight');

192
supabase/schema.sql Normal file
View File

@@ -0,0 +1,192 @@
-- ============================================================
-- Time Tracker App - Teams & Privileges Schema
-- Run this SQL in your Supabase SQL Editor
-- ============================================================
-- 1. Teams table
CREATE TABLE IF NOT EXISTS teams (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
);
-- 2. Team Members table (links users to teams with roles)
CREATE TABLE IF NOT EXISTS team_members (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'manager', 'user')),
joined_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(team_id, user_id)
);
-- 3. User Profiles table (extends auth.users with global role)
CREATE TABLE IF NOT EXISTS user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT DEFAULT '',
phone TEXT DEFAULT '',
company TEXT DEFAULT '',
avatar_url TEXT DEFAULT '',
global_role TEXT NOT NULL DEFAULT 'user' CHECK (global_role IN ('admin', 'manager', 'user')),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_team_members_team_id ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id);
CREATE INDEX IF NOT EXISTS idx_user_profiles_global_role ON user_profiles(global_role);
-- ============================================================
-- Row Level Security (RLS)
-- ============================================================
-- Teams: anyone authenticated can read; only admins can insert/update/delete
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Teams are viewable by authenticated users"
ON teams FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Admins can insert teams"
ON teams FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role = 'admin'
)
);
CREATE POLICY "Admins can update teams"
ON teams FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role = 'admin'
)
);
CREATE POLICY "Admins can delete teams"
ON teams FOR DELETE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role = 'admin'
)
);
-- Team Members: authenticated users can read; admins/managers can manage
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Team members are viewable by authenticated users"
ON team_members FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Admins can insert team members"
ON team_members FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role IN ('admin', 'manager')
)
);
CREATE POLICY "Admins can update team members"
ON team_members FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role IN ('admin', 'manager')
)
);
CREATE POLICY "Admins can delete team members"
ON team_members FOR DELETE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role IN ('admin', 'manager')
)
);
-- User Profiles: users can read all profiles, update their own; admins can update any
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "User profiles are viewable by authenticated users"
ON user_profiles FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can update own profile"
ON user_profiles FOR UPDATE
TO authenticated
USING (auth.uid() = id);
CREATE POLICY "Admins can update any profile"
ON user_profiles FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND global_role = 'admin'
)
);
CREATE POLICY "Users can insert own profile"
ON user_profiles FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = id);
-- ============================================================
-- Trigger: Auto-create user_profiles on signup
-- ============================================================
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.user_profiles (id, full_name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
COALESCE(NEW.raw_user_meta_data->>'avatar_url', '')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- ============================================================
-- Trigger: Auto-update updated_at on user_profiles
-- ============================================================
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_user_profiles_updated_at ON user_profiles;
CREATE TRIGGER update_user_profiles_updated_at
BEFORE UPDATE ON user_profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- ============================================================
-- Seed: Make the first user an admin (adjust the user_id as needed)
-- ============================================================
-- UPDATE user_profiles SET global_role = 'admin' WHERE id = 'YOUR_USER_ID_HERE';