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;
```
### Automatic Session Closure (Optional)
To automatically close active sessions at midnight every day (without deleting history):
1. Make sure the `pg_cron` extension is enabled in your Supabase project (it's usually enabled by default)
2. Run the SQL script in `supabase/close_sessions_cron.sql` in your Supabase SQL Editor
3. The cron job will automatically run daily at 00:00 to close any active sessions
The function identifies sessions that are currently active (where `end_time` is NULL) and sets their `end_time` to midnight, calculating the duration accordingly. This preserves all session history while ensuring no session remains indefinitely open.
## Session Management Features
### Creating New Sessions

View File

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

View File

@@ -5,39 +5,38 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
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.
<meta name="description" content="Time Tracker App for Ficosa employees" />
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Time Tracker">
<meta name="apple-mobile-web-app-capable" content="yes">
<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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<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.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
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>
</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",
"name": "Create React App Sample",
"short_name": "Time Tracker",
"name": "Ficosa Time Tracker App",
"description": "Track your work hours and manage your time efficiently",
"icons": [
{
"src": "favicon.ico",
@@ -8,18 +9,20 @@
"type": "image/x-icon"
},
{
"src": "LOGO_FICOSA.svg",
"type": "image/svg+xml",
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo_ficosa.png",
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#ffffff",
"prefer_related_applications": false
}

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('- 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('- 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-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;
}
* {

View File

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

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

View File

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