Hotfixes and CI/CD
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m51s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m51s
This commit is contained in:
40
.gitea/workflows/deploy.yaml
Normal file
40
.gitea/workflows/deploy.yaml
Normal 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
16
Dockerfile
Normal 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"]
|
||||||
10
README.md
10
README.md
@@ -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
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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
47
public/service-worker.js
Normal 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
51
scripts/closeSessions.js
Normal 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();
|
||||||
1
setup.js
1
setup.js
@@ -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');
|
||||||
26
src/App.css
26
src/App.css
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
135
src/App.js
135
src/App.js
@@ -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>
|
||||||
<AppContent />
|
<>
|
||||||
|
{/* Installation prompt */}
|
||||||
|
{showInstallPrompt && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '15px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
zIndex: '1000',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '400px'
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: '0', fontWeight: 'bold' }}>Install Time Tracker App</p>
|
||||||
|
<p style={{ margin: '5px 0', fontSize: '14px' }}>Add this app to your home screen for faster access and offline functionality.</p>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleInstallClick}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInstallPrompt(false)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: '#666',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AppContent />
|
||||||
|
</>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/components/AdminRoute.js
Normal file
39
src/components/AdminRoute.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const AdminRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, isAdmin, loading } = useAuth();
|
||||||
|
|
||||||
|
// Show loading indicator while checking auth state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="permission-denied">
|
||||||
|
<h1>Loading...</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated - redirect to login
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not admin - show permission denied
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="permission-denied">
|
||||||
|
<div className="permission-denied-content">
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>You do not have permission to access this page.</p>
|
||||||
|
<p>Only administrators can access the admin panel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin - render children
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminRoute;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import 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 (
|
||||||
|
|||||||
13
src/index.js
13
src/index.js
@@ -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
431
src/pages/Admin.css
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/* Admin Page Styles */
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header p {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin Section Base Styles */
|
||||||
|
.admin-section {
|
||||||
|
background: var(--surface-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section:hover {
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 2px solid var(--accent-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Header */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Messages */
|
||||||
|
.error-message {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: var(--error-bg);
|
||||||
|
color: var(--error-text);
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-denied {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 50vh;
|
||||||
|
background: var(--surface-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-denied-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--error-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-denied-content p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ USER MANAGEMENT ============ */
|
||||||
|
.user-list {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table thead {
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table tbody tr:hover {
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role Badge */
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-admin {
|
||||||
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-manager {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-user {
|
||||||
|
background-color: rgba(107, 114, 128, 0.2);
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ PERMISSION MANAGEMENT ============ */
|
||||||
|
.permission-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group select {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group select:hover,
|
||||||
|
.control-group select:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-role {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-role:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-role:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ TIME REPORTS ============ */
|
||||||
|
.report-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filters input {
|
||||||
|
padding: 0.6rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filters input:hover,
|
||||||
|
.report-filters input:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filters span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(600px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-report-card {
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-report-card:hover {
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-report-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table thead {
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table th {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table td {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:hover {
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-summary {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ RESPONSIVE ============ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-page {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filters {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filters input,
|
||||||
|
.btn-refresh {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-role {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-page {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/pages/Admin.js
Normal file
315
src/pages/Admin.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import teamService from '../services/teamService';
|
||||||
|
import './Admin.css';
|
||||||
|
|
||||||
|
const Admin = () => {
|
||||||
|
const { user, userTeams, loadUserTeams } = useAuth();
|
||||||
|
|
||||||
|
// User Management State
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [userLoading, setUserLoading] = useState(false);
|
||||||
|
const [userError, setUserError] = useState('');
|
||||||
|
|
||||||
|
// Permission Management State
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||||
|
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||||
|
const [permissionError, setPermissionError] = useState('');
|
||||||
|
|
||||||
|
// Time Reports State
|
||||||
|
const [reports, setReports] = useState([]);
|
||||||
|
const [reportLoading, setReportLoading] = useState(false);
|
||||||
|
const [reportError, setReportError] = useState('');
|
||||||
|
const [reportStartDate, setReportStartDate] = useState(getDateString(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)));
|
||||||
|
const [reportEndDate, setReportEndDate] = useState(getDateString(new Date()));
|
||||||
|
|
||||||
|
// Load all users on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load initial reports
|
||||||
|
useEffect(() => {
|
||||||
|
loadTeamReports();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper function to format dates
|
||||||
|
function getDateString(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ USER MANAGEMENT ============
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setUserLoading(true);
|
||||||
|
setUserError('');
|
||||||
|
try {
|
||||||
|
const allUsers = await teamService.getAllUsers();
|
||||||
|
setUsers(allUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
setUserError('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setUserLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ PERMISSION MANAGEMENT ============
|
||||||
|
const loadTeamMembers = async (teamId) => {
|
||||||
|
try {
|
||||||
|
const members = await teamService.getTeamMembers(teamId);
|
||||||
|
console.log('Team members:', members);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading team members:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMemberRole = async (teamId, userId, newRole) => {
|
||||||
|
if (!selectedTeam || !selectedUser) {
|
||||||
|
setPermissionError('Please select a team and user first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissionLoading(true);
|
||||||
|
setPermissionError('');
|
||||||
|
try {
|
||||||
|
await teamService.updateMemberRole(teamId, userId, newRole);
|
||||||
|
setPermissionError('');
|
||||||
|
// Reload teams to reflect changes
|
||||||
|
await loadUserTeams(user.id);
|
||||||
|
alert('Permission updated successfully');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating member role:', error);
|
||||||
|
setPermissionError('Failed to update permissions');
|
||||||
|
} finally {
|
||||||
|
setPermissionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ TIME REPORTS ============
|
||||||
|
const loadTeamReports = async () => {
|
||||||
|
setReportLoading(true);
|
||||||
|
setReportError('');
|
||||||
|
try {
|
||||||
|
const startDate = new Date(reportStartDate);
|
||||||
|
const endDate = new Date(reportEndDate);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const reportsData = await teamService.getAllTeamReports(startDate, endDate);
|
||||||
|
setReports(reportsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading reports:', error);
|
||||||
|
setReportError('Failed to load time reports');
|
||||||
|
} finally {
|
||||||
|
setReportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReportDateChange = () => {
|
||||||
|
loadTeamReports();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ RENDER ============
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<header className="admin-header">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
<p>Manage users, permissions, and view team reports</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="admin-sections">
|
||||||
|
{/* USER MANAGEMENT SECTION */}
|
||||||
|
<section className="admin-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<button className="btn-refresh" onClick={fetchUsers} disabled={userLoading}>
|
||||||
|
{userLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userError && <div className="error-message">{userError}</div>}
|
||||||
|
|
||||||
|
<div className="user-list">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p>No users found</p>
|
||||||
|
) : (
|
||||||
|
<table className="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Full Name</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Global Role</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((usr) => (
|
||||||
|
<tr key={usr.id}>
|
||||||
|
<td>{usr.email || 'N/A'}</td>
|
||||||
|
<td>{usr.fullName || 'N/A'}</td>
|
||||||
|
<td>{usr.company || 'N/A'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`role-badge role-${usr.globalRole}`}>
|
||||||
|
{usr.globalRole}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(usr.createdAt).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* PERMISSION MANAGEMENT SECTION */}
|
||||||
|
<section className="admin-section">
|
||||||
|
<h2>Permission Management</h2>
|
||||||
|
|
||||||
|
{permissionError && <div className="error-message">{permissionError}</div>}
|
||||||
|
|
||||||
|
<div className="permission-controls">
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Select User:</label>
|
||||||
|
<select
|
||||||
|
value={selectedUser || ''}
|
||||||
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Choose a user...</option>
|
||||||
|
{users.map((usr) => (
|
||||||
|
<option key={usr.id} value={usr.id}>
|
||||||
|
{usr.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Select Team:</label>
|
||||||
|
<select
|
||||||
|
value={selectedTeam || ''}
|
||||||
|
onChange={(e) => setSelectedTeam(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Choose a team...</option>
|
||||||
|
{userTeams.map((team) => (
|
||||||
|
<option key={team.id} value={team.id}>
|
||||||
|
{team.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="role-buttons">
|
||||||
|
<button
|
||||||
|
className="btn-role"
|
||||||
|
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'admin')}
|
||||||
|
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||||
|
>
|
||||||
|
Set as Admin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-role"
|
||||||
|
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'manager')}
|
||||||
|
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||||
|
>
|
||||||
|
Set as Manager
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-role"
|
||||||
|
onClick={() => updateMemberRole(selectedTeam, selectedUser, 'user')}
|
||||||
|
disabled={!selectedTeam || !selectedUser || permissionLoading}
|
||||||
|
>
|
||||||
|
Set as User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TIME REPORTS SECTION */}
|
||||||
|
<section className="admin-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Team Time Reports</h2>
|
||||||
|
<div className="report-filters">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={reportStartDate}
|
||||||
|
onChange={(e) => setReportStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span>to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={reportEndDate}
|
||||||
|
onChange={(e) => setReportEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-refresh"
|
||||||
|
onClick={handleReportDateChange}
|
||||||
|
disabled={reportLoading}
|
||||||
|
>
|
||||||
|
{reportLoading ? 'Loading...' : 'Load Reports'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reportError && <div className="error-message">{reportError}</div>}
|
||||||
|
|
||||||
|
<div className="reports-container">
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<p>No time reports available for the selected date range</p>
|
||||||
|
) : (
|
||||||
|
reports.map((teamReport) => (
|
||||||
|
<div key={teamReport.teamId} className="team-report-card">
|
||||||
|
<h3>{teamReport.teamName}</h3>
|
||||||
|
<table className="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Total Sessions</th>
|
||||||
|
<th>Total Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{teamReport.userReports.map((userReport, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>{userReport.userEmail}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`role-badge role-${userReport.memberRole}`}>
|
||||||
|
{userReport.memberRole}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{userReport.totalSessions}</td>
|
||||||
|
<td>{formatDuration(userReport.totalDurationMs)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="team-summary">
|
||||||
|
<strong>Team Total Time:</strong> {formatDuration(teamReport.totalDurationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format duration in milliseconds
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!ms || ms === 0) return '0h 0m';
|
||||||
|
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours === 0) return `${minutes}m`;
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
@@ -315,14 +315,14 @@ const Sessions = () => {
|
|||||||
|
|
||||||
const result = await withTimeout(
|
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
558
src/pages/Teams.css
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
/* Teams Page Styles */
|
||||||
|
|
||||||
|
.teams-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header p {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Layout */
|
||||||
|
.teams-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Teams List Section */
|
||||||
|
.teams-list-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: var(--surface-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-list-section h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 2px solid var(--accent-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-remove {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-remove:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Message */
|
||||||
|
.error-message {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--error-bg);
|
||||||
|
color: var(--error-text);
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Team Form */
|
||||||
|
.create-team-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-team-form input,
|
||||||
|
.create-team-form textarea {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-team-form input:focus,
|
||||||
|
.create-team-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-team-form button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-team-form button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Teams Grid */
|
||||||
|
.teams-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card.active {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-role {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-teams-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-teams-section h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Team Details Section */
|
||||||
|
.team-details-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
background: var(--surface-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 2px solid var(--accent-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-description-box {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Member Form */
|
||||||
|
.add-member-form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-member-form h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input,
|
||||||
|
.form-row select,
|
||||||
|
.form-row button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input,
|
||||||
|
.form-row select {
|
||||||
|
background-color: var(--surface-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input:focus,
|
||||||
|
.form-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row button {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Members Section */
|
||||||
|
.members-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:hover {
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-email {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-admin {
|
||||||
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-manager {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-user {
|
||||||
|
background-color: rgba(107, 114, 128, 0.2);
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ RESPONSIVE ============ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.teams-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-details-section {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.teams-page {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-list-section,
|
||||||
|
.team-details-section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input,
|
||||||
|
.form-row select,
|
||||||
|
.form-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header h2 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.teams-page {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-list-section,
|
||||||
|
.team-details-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item .btn-remove {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/pages/Teams.js
Normal file
350
src/pages/Teams.js
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import teamService from '../services/teamService';
|
||||||
|
import './Teams.css';
|
||||||
|
|
||||||
|
const Teams = () => {
|
||||||
|
const { user, userTeams, loadUserTeams, isAdmin, isManager } = useAuth();
|
||||||
|
|
||||||
|
// State for teams
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [allTeams, setAllTeams] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// State for creating/editing teams
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newTeamName, setNewTeamName] = useState('');
|
||||||
|
const [newTeamDescription, setNewTeamDescription] = useState('');
|
||||||
|
const [creatingTeam, setCreatingTeam] = useState(false);
|
||||||
|
|
||||||
|
// State for team details modal
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState(null);
|
||||||
|
const [teamMembers, setTeamMembers] = useState([]);
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false);
|
||||||
|
|
||||||
|
// State for adding members
|
||||||
|
const [newMemberEmail, setNewMemberEmail] = useState('');
|
||||||
|
const [newMemberRole, setNewMemberRole] = useState('user');
|
||||||
|
const [addingMember, setAddingMember] = useState(false);
|
||||||
|
|
||||||
|
// Load user's teams on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTeams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load all teams if user is admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
loadAllTeams();
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// Load team members when selected team changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTeamId) {
|
||||||
|
loadTeamMembers(selectedTeamId);
|
||||||
|
}
|
||||||
|
}, [selectedTeamId]);
|
||||||
|
|
||||||
|
const loadTeams = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await loadUserTeams(user.id);
|
||||||
|
setTeams(userTeams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading teams:', err);
|
||||||
|
setError('Failed to load teams');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllTeams = async () => {
|
||||||
|
try {
|
||||||
|
const allTeamsData = await teamService.getTeams();
|
||||||
|
setAllTeams(allTeamsData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading all teams:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTeamMembers = async (teamId) => {
|
||||||
|
setMembersLoading(true);
|
||||||
|
try {
|
||||||
|
const members = await teamService.getTeamMembers(teamId);
|
||||||
|
setTeamMembers(members);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading team members:', err);
|
||||||
|
setTeamMembers([]);
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTeam = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTeamName.trim()) {
|
||||||
|
setError('Team name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingTeam(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const newTeam = await teamService.createTeam(newTeamName, newTeamDescription);
|
||||||
|
setNewTeamName('');
|
||||||
|
setNewTeamDescription('');
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setError('');
|
||||||
|
await loadTeams();
|
||||||
|
if (isAdmin) await loadAllTeams();
|
||||||
|
alert('Team created successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating team:', err);
|
||||||
|
setError('Failed to create team');
|
||||||
|
} finally {
|
||||||
|
setCreatingTeam(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMemberEmail.trim() || !selectedTeamId) {
|
||||||
|
setError('Please enter an email and select a team');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddingMember(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// Find user by email - first get all users and find match
|
||||||
|
const allUsers = await teamService.getAllUsers();
|
||||||
|
const foundUser = allUsers.find((u) => u.email === newMemberEmail.trim());
|
||||||
|
|
||||||
|
if (!foundUser) {
|
||||||
|
setError('User not found');
|
||||||
|
setAddingMember(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await teamService.addMember(selectedTeamId, foundUser.id, newMemberRole);
|
||||||
|
setNewMemberEmail('');
|
||||||
|
setNewMemberRole('user');
|
||||||
|
setError('');
|
||||||
|
await loadTeamMembers(selectedTeamId);
|
||||||
|
alert('Member added successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding member:', err);
|
||||||
|
setError('Failed to add member');
|
||||||
|
} finally {
|
||||||
|
setAddingMember(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (teamId, userId) => {
|
||||||
|
if (!window.confirm('Are you sure you want to remove this member?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await teamService.removeMember(teamId, userId);
|
||||||
|
setError('');
|
||||||
|
await loadTeamMembers(teamId);
|
||||||
|
alert('Member removed successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing member:', err);
|
||||||
|
setError('Failed to remove member');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTeam = async (teamId) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this team? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await teamService.deleteTeam(teamId);
|
||||||
|
setError('');
|
||||||
|
setSelectedTeamId(null);
|
||||||
|
await loadTeams();
|
||||||
|
if (isAdmin) await loadAllTeams();
|
||||||
|
alert('Team deleted successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting team:', err);
|
||||||
|
setError('Failed to delete team');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTeam = [...teams, ...allTeams].find((t) => t.id === selectedTeamId);
|
||||||
|
const canManageTeam = isManager && (selectedTeam?.createdBy === user.id || isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="teams-page">
|
||||||
|
<header className="teams-header">
|
||||||
|
<h1>Teams & Groups</h1>
|
||||||
|
<p>Manage your team memberships and collaboration groups</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<div className="teams-layout">
|
||||||
|
{/* Teams List */}
|
||||||
|
<div className="teams-list-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>My Teams</h2>
|
||||||
|
{(isManager || isAdmin) && (
|
||||||
|
<button className="btn-primary" onClick={() => setShowCreateForm(!showCreateForm)}>
|
||||||
|
{showCreateForm ? 'Cancel' : '+ New Team'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateForm && (isManager || isAdmin) && (
|
||||||
|
<form onSubmit={handleCreateTeam} className="create-team-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Team name"
|
||||||
|
value={newTeamName}
|
||||||
|
onChange={(e) => setNewTeamName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={newTeamDescription}
|
||||||
|
onChange={(e) => setNewTeamDescription(e.target.value)}
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creatingTeam}>
|
||||||
|
{creatingTeam ? 'Creating...' : 'Create Team'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading">Loading teams...</div>
|
||||||
|
) : teams.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>You are not a member of any teams yet.</p>
|
||||||
|
{isManager && <p>Create a team or ask an admin to add you to one.</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="teams-grid">
|
||||||
|
{teams.map((team) => (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className={`team-card ${selectedTeamId === team.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedTeamId(team.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<h3>{team.name}</h3>
|
||||||
|
<p className="team-description">{team.description || 'No description'}</p>
|
||||||
|
<div className="team-meta">
|
||||||
|
<span className="team-role">{team.memberRole}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && allTeams.length > 0 && (
|
||||||
|
<div className="all-teams-section">
|
||||||
|
<h2>All Teams (Admin View)</h2>
|
||||||
|
<div className="teams-grid">
|
||||||
|
{allTeams.map((team) => (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className={`team-card ${selectedTeamId === team.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedTeamId(team.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<h3>{team.name}</h3>
|
||||||
|
<p className="team-description">{team.description || 'No description'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Details */}
|
||||||
|
{selectedTeamId && selectedTeam && (
|
||||||
|
<div className="team-details-section">
|
||||||
|
<div className="details-header">
|
||||||
|
<h2>{selectedTeam.name}</h2>
|
||||||
|
{canManageTeam && (
|
||||||
|
<button className="btn-danger" onClick={() => handleDeleteTeam(selectedTeamId)}>
|
||||||
|
Delete Team
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-description-box">
|
||||||
|
<p>{selectedTeam.description || 'No description provided'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Form */}
|
||||||
|
{canManageTeam && (
|
||||||
|
<form onSubmit={handleAddMember} className="add-member-form">
|
||||||
|
<h3>Add Member</h3>
|
||||||
|
<div className="form-row">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Member email"
|
||||||
|
value={newMemberEmail}
|
||||||
|
onChange={(e) => setNewMemberEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select value={newMemberRole} onChange={(e) => setNewMemberRole(e.target.value)}>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" disabled={addingMember}>
|
||||||
|
{addingMember ? 'Adding...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team Members List */}
|
||||||
|
<div className="members-section">
|
||||||
|
<h3>Team Members ({teamMembers.length})</h3>
|
||||||
|
{membersLoading ? (
|
||||||
|
<div className="loading">Loading members...</div>
|
||||||
|
) : teamMembers.length === 0 ? (
|
||||||
|
<div className="empty-state">No members in this team</div>
|
||||||
|
) : (
|
||||||
|
<div className="members-list">
|
||||||
|
{teamMembers.map((member) => (
|
||||||
|
<div key={member.userId} className="member-item">
|
||||||
|
<div className="member-info">
|
||||||
|
<div className="member-avatar">
|
||||||
|
{(member.userEmail || member.userId).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="member-details">
|
||||||
|
<p className="member-email">{member.userEmail}</p>
|
||||||
|
<span className={`role-badge role-${member.role}`}>{member.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManageTeam && member.userId !== user.id && (
|
||||||
|
<button
|
||||||
|
className="btn-remove"
|
||||||
|
onClick={() => handleRemoveMember(selectedTeamId, member.userId)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Teams;
|
||||||
424
src/services/teamService.js
Normal file
424
src/services/teamService.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
|
||||||
|
class TeamService {
|
||||||
|
// ─── Team CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createTeam(name, description = '') {
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('User not authenticated');
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.insert({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by: user.id
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Auto-add the creator as team admin
|
||||||
|
await this.addMember(data.id, user.id, 'admin');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
createdBy: data.created_by,
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating team:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTeam(teamId, name, description) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.update({ name, description })
|
||||||
|
.eq('id', teamId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
createdBy: data.created_by,
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating team:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTeam(teamId) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.delete()
|
||||||
|
.eq('id', teamId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting team:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeams() {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data.map(record => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description,
|
||||||
|
createdBy: record.created_by,
|
||||||
|
createdAt: record.created_at
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching teams:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamById(teamId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', teamId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
createdBy: data.created_by,
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching team:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Team Member Management ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async addMember(teamId, userId, role = 'user') {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.insert({
|
||||||
|
team_id: teamId,
|
||||||
|
user_id: userId,
|
||||||
|
role
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
teamId: data.team_id,
|
||||||
|
userId: data.user_id,
|
||||||
|
role: data.role,
|
||||||
|
joinedAt: data.joined_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding team member:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(teamId, userId) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.delete()
|
||||||
|
.eq('team_id', teamId)
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing team member:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMemberRole(teamId, userId, role) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.update({ role })
|
||||||
|
.eq('team_id', teamId)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
teamId: data.team_id,
|
||||||
|
userId: data.user_id,
|
||||||
|
role: data.role,
|
||||||
|
joinedAt: data.joined_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating member role:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamMembers(teamId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.select('id, user_id, role, joined_at, user_profiles(full_name, avatar_url)')
|
||||||
|
.eq('team_id', teamId)
|
||||||
|
.order('joined_at', { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data.map(record => ({
|
||||||
|
id: record.id,
|
||||||
|
teamId: teamId,
|
||||||
|
userId: record.user_id,
|
||||||
|
role: record.role,
|
||||||
|
joinedAt: record.joined_at,
|
||||||
|
fullName: record.user_profiles?.full_name || '',
|
||||||
|
avatarUrl: record.user_profiles?.avatar_url || ''
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching team members:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserTeams(userId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.select('team_id, role, joined_at, teams(id, name, description, created_at)')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data.map(record => ({
|
||||||
|
teamId: record.team_id,
|
||||||
|
role: record.role,
|
||||||
|
joinedAt: record.joined_at,
|
||||||
|
team: {
|
||||||
|
id: record.teams.id,
|
||||||
|
name: record.teams.name,
|
||||||
|
description: record.teams.description,
|
||||||
|
createdAt: record.teams.created_at
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user teams:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── User Profile Queries ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getUserProfile(userId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
fullName: data.full_name,
|
||||||
|
phone: data.phone,
|
||||||
|
company: data.company,
|
||||||
|
avatarUrl: data.avatar_url,
|
||||||
|
globalRole: data.global_role,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
updatedAt: data.updated_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserProfile(userId, profileData) {
|
||||||
|
try {
|
||||||
|
const updateFields = {};
|
||||||
|
if (profileData.fullName !== undefined) updateFields.full_name = profileData.fullName;
|
||||||
|
if (profileData.phone !== undefined) updateFields.phone = profileData.phone;
|
||||||
|
if (profileData.company !== undefined) updateFields.company = profileData.company;
|
||||||
|
if (profileData.avatarUrl !== undefined) updateFields.avatar_url = profileData.avatarUrl;
|
||||||
|
if (profileData.globalRole !== undefined) updateFields.global_role = profileData.globalRole;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.update(updateFields)
|
||||||
|
.eq('id', userId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
fullName: data.full_name,
|
||||||
|
phone: data.phone,
|
||||||
|
company: data.company,
|
||||||
|
avatarUrl: data.avatar_url,
|
||||||
|
globalRole: data.global_role,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
updatedAt: data.updated_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllUsers() {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data.map(record => ({
|
||||||
|
id: record.id,
|
||||||
|
fullName: record.full_name,
|
||||||
|
phone: record.phone,
|
||||||
|
company: record.company,
|
||||||
|
avatarUrl: record.avatar_url,
|
||||||
|
globalRole: record.global_role,
|
||||||
|
createdAt: record.created_at,
|
||||||
|
updatedAt: record.updated_at
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Team Time Reports ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getTeamTimeReport(teamId, startDate, endDate) {
|
||||||
|
try {
|
||||||
|
// Get all members of the team
|
||||||
|
const members = await this.getTeamMembers(teamId);
|
||||||
|
const memberIds = members.map(m => m.userId);
|
||||||
|
|
||||||
|
if (memberIds.length === 0) {
|
||||||
|
return { teamId, members: [], totalDurationMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build date range query for timers
|
||||||
|
let query = supabase
|
||||||
|
.from('timers')
|
||||||
|
.select('user_id, start_time, end_time, duration_ms')
|
||||||
|
.in('user_id', memberIds);
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query = query.gte('start_time', new Date(startDate).toISOString());
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
query = query.lte('end_time', new Date(endDate).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Aggregate time by user
|
||||||
|
const userTotals = {};
|
||||||
|
data.forEach(record => {
|
||||||
|
const uid = record.user_id;
|
||||||
|
if (!userTotals[uid]) {
|
||||||
|
userTotals[uid] = { userId: uid, totalDurationMs: 0, sessionCount: 0 };
|
||||||
|
}
|
||||||
|
userTotals[uid].totalDurationMs += record.duration_ms || 0;
|
||||||
|
userTotals[uid].sessionCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich with member info
|
||||||
|
const memberReports = members.map(member => {
|
||||||
|
const totals = userTotals[member.userId] || { totalDurationMs: 0, sessionCount: 0 };
|
||||||
|
return {
|
||||||
|
userId: member.userId,
|
||||||
|
fullName: member.fullName,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
role: member.role,
|
||||||
|
totalDurationMs: totals.totalDurationMs,
|
||||||
|
sessionCount: totals.sessionCount
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDurationMs = memberReports.reduce((sum, r) => sum + r.totalDurationMs, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamId,
|
||||||
|
members: memberReports,
|
||||||
|
totalDurationMs
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching team time report:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllTeamReports(startDate, endDate) {
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('User not authenticated');
|
||||||
|
|
||||||
|
// Get all teams the user can see
|
||||||
|
const teams = await this.getTeams();
|
||||||
|
const reports = [];
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
const report = await this.getTeamTimeReport(team.id, startDate, endDate);
|
||||||
|
reports.push({
|
||||||
|
...report,
|
||||||
|
teamName: team.name,
|
||||||
|
teamDescription: team.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all team reports:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamService = new TeamService();
|
||||||
|
export default teamService;
|
||||||
48
supabase/close_sessions_cron.sql
Normal file
48
supabase/close_sessions_cron.sql
Normal 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
192
supabase/schema.sql
Normal 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';
|
||||||
Reference in New Issue
Block a user