diff --git a/backend/core/settings.py b/backend/core/settings.py index d6b46a9..705f6d7 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -126,7 +126,9 @@ STATIC_URL = 'static/' # CORS configuration CORS_ALLOWED_ORIGINS = [ "http://localhost:1028", - "http://127.0.0.1:1028" + "http://127.0.0.1:1028", + "http://192.168.1.19:1028", + "https://boulangerie.louismazin.ovh" ] # REST Framework & JWT settings diff --git a/backend/users/serializers.py b/backend/users/serializers.py index ae7aaa4..0691dee 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -11,7 +11,8 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'date_joined'] + + fields = ['id', 'username', 'first_name', 'last_name', 'is_admin', 'date_joined'] def get_is_admin(self, obj): return obj.is_superuser or obj.is_staff @@ -23,7 +24,8 @@ class RegisterSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['username', 'password', 'email', 'first_name', 'last_name', 'invite'] + + fields = ['username', 'password', 'first_name', 'last_name', 'invite'] def validate_invite(self, value): try: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 264fec1..2887fa1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "app", "version": "0.0.0", "dependencies": { + "@zxing/browser": "^0.2.1", "axios": "^1.20.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -666,6 +667,41 @@ } } }, + "node_modules/@zxing/browser": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz", + "integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.23.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz", + "integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.3.1" + }, + "engines": { + "node": ">= 24.0.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1570,6 +1606,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 68d0035..bbfb98c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@zxing/browser": "^0.2.1", "axios": "^1.20.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/App.css b/frontend/src/App.css index 18ecc92..4eb4b64 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,13 +5,14 @@ } main { - width: min(1120px, 100%); + width: min(var(--content-max), 100%); flex: 1; margin: 0 auto; - padding: 2.5rem 1.5rem 4rem; + padding: 2rem clamp(1.25rem, 3vw, 2.75rem); } footer { + flex-shrink: 0; padding: 1.5rem; color: var(--text-muted); font-size: 0.85rem; @@ -176,10 +177,20 @@ form { } .card { - max-width: 900px; + width: 100%; + max-width: var(--content-max); margin: 0 auto; } +.admin-page, +.weekly-summary-page, +.time-clock-page, +.profile-card, +.home-container { + width: 100%; + max-width: var(--content-max); +} + .card h2, .profile-card h2 { margin: 0 0 1.5rem; @@ -294,7 +305,7 @@ tbody tr:hover { @media (max-width: 700px) { main { - padding: 1.25rem 1rem 2.5rem; + padding: 1.25rem 1rem; } .table-responsive { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 72d1730..7932070 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,7 +5,6 @@ import ProtectedRoute from './components/ProtectedRoute'; import AdminRoute from './components/AdminRoute'; import Header from './components/Header'; import AdminUsersPage from './pages/AdminUsersPage'; -import AdminInvitePage from './pages/AdminInvitePage'; import RegisterPage from './pages/RegisterPage'; import TimeClockPage from './pages/TimeClockPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage'; @@ -37,11 +36,6 @@ function App() { } /> - - - - } /> diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index dec3538..40b8798 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,8 +1,9 @@ import axios from 'axios'; -// Utiliser l'URL correcte pour le backend +const apiHost = window.location.hostname; + const api = axios.create({ - baseURL: import.meta.env.PROD ? '/api' : 'http://localhost:1029/api', + baseURL: import.meta.env.PROD ? '/api' : `http://${apiHost}:1029/api`, }); // Ajouter un intercepteur pour gérer les erreurs de réseau diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css index 22029e9..f910d50 100644 --- a/frontend/src/components/Header.css +++ b/frontend/src/components/Header.css @@ -14,7 +14,7 @@ display: flex; align-items: center; justify-content: space-between; - max-width: 1120px; + width: min(var(--content-max), 100%); min-height: 76px; margin: 0 auto; } diff --git a/frontend/src/components/WeeklySchedule.css b/frontend/src/components/WeeklySchedule.css index 8007d55..d29b501 100644 --- a/frontend/src/components/WeeklySchedule.css +++ b/frontend/src/components/WeeklySchedule.css @@ -1,120 +1,211 @@ +.weekly-schedule { background: #fff; border: 1px solid #e7e3dc; border-radius: 14px; padding: 24px; margin: 0; color: #2b2926; } .weekly-schedule { - background: white; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - padding: 20px; - margin: 20px 0; + margin: 0; + padding: 24px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); } -.weekly-schedule.loading { +.weekly-schedule.loading, +.weekly-schedule.schedule-error { + padding: 40px 24px; text-align: center; - padding: 40px; +} + +.schedule-error { + color: var(--danger); + background: #fff5f1; + border-color: #efcfc3; } .schedule-header { - margin-bottom: 20px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; } .schedule-header h2 { - color: #333; - margin-bottom: 15px; - font-size: 1.5em; + margin: 0; + color: var(--text); + font-size: 1.4rem; + line-height: 1.2; } .week-navigation { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 20px; - flex-wrap: wrap; + justify-content: flex-end; gap: 10px; } .current-week { - font-weight: bold; - color: #555; + min-width: 145px; + color: var(--text-muted); + font-size: .84rem; + font-weight: 700; + text-align: center; + white-space: nowrap; } .nav-button { - background: #007bff; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; + padding: 9px 12px; + color: var(--text); + font-size: .78rem; + font-weight: 700; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background 0.3s; + transition: background var(--transition), border-color var(--transition), transform var(--transition); } .nav-button:hover { - background: #0056b3; + background: var(--accent-soft); + border-color: #d9bca8; +} + +.nav-button:active { + transform: translateY(1px); } .schedule-table { - width: 100%; - border-collapse: collapse; + display: grid; + grid-template-columns: 70px repeat(7, minmax(105px, 1fr)); + min-width: 810px; + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + scrollbar-color: #cbbbae transparent; + scrollbar-width: thin; } -.schedule-header-row { +.schedule-day-header { display: flex; - border-bottom: 2px solid #eee; - font-weight: bold; - background-color: #f8f9fa; + min-height: 58px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 10px 6px; + color: var(--text-muted); + font-size: .76rem; + font-weight: 700; + text-transform: capitalize; + background: var(--surface-muted); + border-right: 1px solid var(--border); + border-bottom: 2px solid #dbcfc3; } -.schedule-day-header, -.schedule-time-header { - padding: 12px; - text-align: center; - flex: 1; +.schedule-day-header:first-child { + color: #9a8a7d; + font-size: .68rem; + letter-spacing: .04em; + text-transform: uppercase; } -.schedule-row { - display: flex; - border-bottom: 1px solid #eee; - transition: background-color 0.2s; +.schedule-day-header strong { + color: var(--text); + font-size: 1.15rem; + line-height: 1; } -.schedule-row:hover { - background-color: #f5f5f5; +.schedule-time-axis { + height: var(--schedule-height); + background: #f8f5f1; + border-right: 1px solid var(--border); } -.schedule-day, -.schedule-time { - padding: 12px; - flex: 1; - text-align: center; +.schedule-time-axis span { + display: block; + height: 64px; + padding: 7px 9px 0 0; + color: #998c81; + font-size: .68rem; + text-align: right; + transform: translateY(-8px); } -.schedule-day { - font-weight: 500; - color: #333; +.schedule-day-column { + position: relative; + height: var(--schedule-height); + overflow: hidden; + background: var(--surface); + border-right: 1px solid var(--border); } -.schedule-time { - color: #666; +.schedule-day-column:last-child { + border-right: 0; } -@media (max-width: 768px) { - .schedule-header-row, - .schedule-row { - flex-direction: column; - align-items: flex-start; +.schedule-hour-line { + display: block; + height: 64px; + border-bottom: 1px solid #f0ebe6; +} + +.attendance-block { + position: absolute; + right: 5px; + left: 5px; + min-height: 28px; + padding: 7px 8px; + overflow: hidden; + color: #633c2d; + font-size: .72rem; + line-height: 1.25; + background: var(--accent-soft); + border-left: 4px solid var(--accent); + border-radius: 7px; + box-shadow: 0 3px 8px rgba(75, 45, 26, .12); +} + +.attendance-block strong, +.attendance-block span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attendance-block strong { + color: #633c2d; +} + +.attendance-block span { + margin-top: 3px; + color: #8b5d48; + font-size: .65rem; +} + +@media (max-width: 820px) { + .weekly-schedule { + padding: 18px 12px; } - - .schedule-day-header, - .schedule-time-header, - .schedule-day, - .schedule-time { - width: 100%; - text-align: left; - padding: 10px; + + .schedule-header { + display: block; } - + + .schedule-header h2 { + margin-bottom: 16px; + } + .week-navigation { - flex-direction: column; - align-items: stretch; + display: grid; + grid-template-columns: 1fr 1fr; } - + + .current-week { + grid-column: 1 / -1; + grid-row: 1; + } + .nav-button { width: 100%; } diff --git a/frontend/src/components/WeeklySchedule.jsx b/frontend/src/components/WeeklySchedule.jsx deleted file mode 100644 index 5ae7948..0000000 --- a/frontend/src/components/WeeklySchedule.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import attendanceStorage from '../services/attendanceStorage'; -import './WeeklySchedule.css'; - -const WeeklySchedule = ({ userId }) => { - const [attendances, setAttendances] = useState([]); - const [currentWeek, setCurrentWeek] = useState(new Date()); - const [loading, setLoading] = useState(true); - - // Charger les pointages depuis le service - useEffect(() => { - const loadAttendances = () => { - const userAttendances = attendanceStorage.getAttendancesByUser(userId); - setAttendances(userAttendances); - setLoading(false); - }; - - loadAttendances(); - }, [userId]); - - // Fonction pour obtenir les jours de la semaine - const getWeekDays = (date) => { - const days = []; - const startOfWeek = new Date(date); - const day = startOfWeek.getDay(); - const diff = startOfWeek.getDate() - day; - startOfWeek.setDate(diff); - - for (let i = 0; i < 7; i++) { - const day = new Date(startOfWeek); - day.setDate(startOfWeek.getDate() + i); - days.push(day); - } - return days; - }; - - // Fonction pour formater la date - const formatDate = (date) => { - return date.toLocaleDateString('fr-FR', { - weekday: 'short', - day: 'numeric' - }); - }; - - // Fonction pour obtenir les heures de présence pour un jour - const getHoursForDay = (date) => { - const attendance = attendances.find(a => - new Date(a.date).toDateString() === date.toDateString() - ); - - if (attendance) { - return `${attendance.startTime} - ${attendance.endTime}`; - } - return 'Absent'; - }; - - // Fonction pour naviguer entre les semaines - const navigateWeek = (direction) => { - const newWeek = new Date(currentWeek); - newWeek.setDate(currentWeek.getDate() + (direction * 7)); - setCurrentWeek(newWeek); - }; - - if (loading) { - return
Chargement...
; - } - - const weekDays = getWeekDays(currentWeek); - const weekDates = weekDays.map(day => formatDate(day)); - - return ( -
-
-

Emploi du temps hebdomadaire

-
- - - Semaine du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} - au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} - - -
-
- -
-
-
Jour
-
Heures de présence
-
- - {weekDays.map((day, index) => ( -
-
{weekDates[index]}
-
{getHoursForDay(day)}
-
- ))} -
-
- ); -}; - -export default WeeklySchedule; \ No newline at end of file diff --git a/frontend/src/main.css b/frontend/src/main.css index b504f26..9d5d88f 100644 --- a/frontend/src/main.css +++ b/frontend/src/main.css @@ -15,6 +15,7 @@ --radius: 16px; --radius-sm: 10px; --transition: 180ms ease; + --content-max: 1440px; --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sans); diff --git a/frontend/src/pages/AdminInvitePage.jsx b/frontend/src/pages/AdminInvitePage.jsx index e028054..3fed417 100644 --- a/frontend/src/pages/AdminInvitePage.jsx +++ b/frontend/src/pages/AdminInvitePage.jsx @@ -68,16 +68,6 @@ const AdminInvitePage = () => {

)} - -
-

Comment cela fonctionne-t-il ?

-
    -
  • Les utilisateurs doivent utiliser un lien d'invitation pour créer un compte
  • -
  • Cela permet de contrôler qui peut accéder à l'application
  • -
  • Les nouveaux comptes sont créés avec des permissions standard
  • -
  • Vous pouvez promouvoir des utilisateurs en administrateurs depuis la page de gestion
  • -
-
); }; diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index b383c82..47425f4 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import api from '../api/axios'; import { userApi } from '../api/users'; @@ -7,7 +6,33 @@ const AdminUsersPage = () => { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const navigate = useNavigate(); + const [inviteLink, setInviteLink] = useState(''); + const [isCopied, setIsCopied] = useState(false); + + const generateInviteLink = async () => { + try { + setError(''); + const response = await api.post('/users/invitations/'); + const newInviteLink = `${window.location.origin}/register?invite=${response.data.token}`; + setInviteLink(newInviteLink); + setIsCopied(false); + } catch (error) { + setError('Erreur lors de la génération du lien d\'invitation'); + console.error(error); + } + }; + + const copyToClipboard = () => { + if (inviteLink) { + navigator.clipboard.writeText(inviteLink).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }).catch((error) => { + setError('Impossible de copier le lien.'); + console.error(error); + }); + } + }; useEffect(() => { const loadUsers = async () => { @@ -71,9 +96,28 @@ const AdminUsersPage = () => { {error &&
{error}
}
- + {inviteLink && ( +
+ + +
+ )}
@@ -81,7 +125,6 @@ const AdminUsersPage = () => { Nom d'utilisateur - Email Rôle Inscrit le Actions @@ -91,7 +134,6 @@ const AdminUsersPage = () => { {users.map((user) => ( {user.username} - {user.email} {user.is_admin ? ( Administrateur diff --git a/frontend/src/pages/HomePage.css b/frontend/src/pages/HomePage.css index 6a85383..4b955f1 100644 --- a/frontend/src/pages/HomePage.css +++ b/frontend/src/pages/HomePage.css @@ -1,10 +1,10 @@ .home-container { - max-width: 720px; margin: 0 auto; } .profile-card { - max-width: 720px; + margin-right: auto; + margin-left: auto; } .user-info { diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 093b977..71ddace 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -35,17 +35,13 @@ const HomePage = () => { Nom d'utilisateur: {userData.username}
-
- Email: - {userData.email || 'Non spécifié'} -
Admin: {userData.is_admin ? 'Oui' : 'Non'}
Date d'inscription: - {userData.date_joined || 'Non spécifié'} + {new Date(user.date_joined).toLocaleDateString('fr-FR') || 'Non spécifié'}
)} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 93d13a7..30e854f 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -6,7 +6,7 @@ import './LoginPage.css'; const RegisterPage = () => { const invite = new URLSearchParams(useLocation().search).get('invite') || ''; const navigate = useNavigate(); - const [form, setForm] = useState({ username: '', email: '', password: '' }); + const [form, setForm] = useState({ username: '', password: '' }); const [error, setError] = useState(''); const [isCreated, setIsCreated] = useState(false); @@ -37,7 +37,6 @@ const RegisterPage = () => { {error &&
{error}
}
setForm({ ...form, username: event.target.value })} />
-
setForm({ ...form, email: event.target.value })} />
setForm({ ...form, password: event.target.value })} />
diff --git a/frontend/src/pages/TimeClockPage.css b/frontend/src/pages/TimeClockPage.css index 858a879..2758bcd 100644 --- a/frontend/src/pages/TimeClockPage.css +++ b/frontend/src/pages/TimeClockPage.css @@ -1,5 +1,6 @@ .time-clock-page { - max-width: 640px; + margin-right: auto; + margin-left: auto; } .time-clock-intro { diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index 1435e0e..537f4c2 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -1,11 +1,15 @@ import { useEffect, useRef, useState } from 'react'; +import { BrowserQRCodeReader } from '@zxing/browser'; import './TimeClockPage.css'; import { timeApi } from '../api/time'; +import { useAuth } from '../context/AuthContext'; const TimeClockPage = () => { const videoRef = useRef(null); const streamRef = useRef(null); const scanFrameRef = useRef(null); + const qrControlsRef = useRef(null); + const { isAdmin } = useAuth(); const [isScanning, setIsScanning] = useState(false); const [scanResult, setScanResult] = useState(''); const [error, setError] = useState(''); @@ -116,6 +120,8 @@ const TimeClockPage = () => { }; const stopScanner = () => { + qrControlsRef.current?.stop(); + qrControlsRef.current = null; if (scanFrameRef.current) { cancelAnimationFrame(scanFrameRef.current); scanFrameRef.current = null; @@ -134,45 +140,31 @@ const TimeClockPage = () => { setError(''); setScanResult(''); - if (!('BarcodeDetector' in window)) { - setError('La lecture QR n’est pas prise en charge par ce navigateur. Utilisez Chrome ou Edge à jour.'); + if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { + setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.'); return; } try { - const supportedFormats = await window.BarcodeDetector.getSupportedFormats(); - if (!supportedFormats.includes('qr_code')) { - setError('La lecture des QR codes n’est pas disponible sur cet appareil.'); + if (!navigator.mediaDevices?.getUserMedia) { + setError('Ce navigateur ne permet pas l’accès à la caméra.'); return; } - const stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: 'environment' } }, - audio: false, - }); - streamRef.current = stream; - videoRef.current.srcObject = stream; - await videoRef.current.play(); setIsScanning(true); - - const detector = new window.BarcodeDetector({ formats: ['qr_code'] }); - const scan = async () => { - if (!videoRef.current || videoRef.current.readyState < 2) { - scanFrameRef.current = requestAnimationFrame(scan); - return; - } - - const codes = await detector.detect(videoRef.current); - if (codes.length > 0) { - setScanResult(codes[0].rawValue); - stopScanner(); - return; - } - scanFrameRef.current = requestAnimationFrame(scan); - }; - scanFrameRef.current = requestAnimationFrame(scan); + const reader = new BrowserQRCodeReader(); + qrControlsRef.current = await reader.decodeFromConstraints( + { video: { facingMode: { ideal: 'environment' } }, audio: false }, + videoRef.current, + (result) => { + if (result) { + setScanResult(result.getText()); + stopScanner(); + } + }, + ); } catch (scannerError) { - setError('Impossible d’accéder à la caméra. Vérifiez les permissions de votre navigateur.'); + setError('Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.'); console.error(scannerError); stopScanner(); } @@ -189,8 +181,12 @@ const TimeClockPage = () => { return date.toLocaleDateString('fr-FR'); }; - const calculateTotalHours = () => { - return timeEntries.reduce((total, entry) => total + (entry.duration || 0), 0); + const formatDuration = (duration) => { + const totalMinutes = Math.round(Number(duration || 0) * 60); + const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0'); + const minutes = String(totalMinutes % 60).padStart(2, '0'); + + return `${hours}h${minutes}`; }; return ( @@ -211,7 +207,7 @@ const TimeClockPage = () => { ) : ( )} @@ -231,7 +227,7 @@ const TimeClockPage = () => { {/* Add new entry form */} - {user.is_admin ? ( + {isAdmin && (

Ajouter un pointage manuel

@@ -265,10 +261,7 @@ const TimeClockPage = () => {
- ) : ( - <> - - ) } + )} {/* Time entries list */}
@@ -282,36 +275,42 @@ const TimeClockPage = () => { + {isAdmin && } - + {isAdmin && } {timeEntries.map((entry) => ( + {isAdmin && + + } - - + + {isAdmin && + + } ))} @@ -319,11 +318,6 @@ const TimeClockPage = () => { )} - - {/* Total hours */} -
-

Total des heures: {calculateTotalHours().toFixed(2)}h

-
); diff --git a/frontend/src/pages/WeeklySummaryPage.css b/frontend/src/pages/WeeklySummaryPage.css new file mode 100644 index 0000000..e517404 --- /dev/null +++ b/frontend/src/pages/WeeklySummaryPage.css @@ -0,0 +1,271 @@ +.weekly-schedule { + margin: 0; + padding: 24px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.weekly-schedule.loading, +.weekly-schedule.schedule-error { + padding: 40px 24px; + text-align: center; +} + +.schedule-error { + color: var(--danger); + background: #fff5f1; + border-color: #efcfc3; +} + +.schedule-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} + +.schedule-header h2 { + margin: 0; + color: var(--text); + font-size: 1.4rem; + line-height: 1.2; +} + +.schedule-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin: -6px 0 18px; + padding: 10px 12px; + color: var(--text-muted); + font-size: .78rem; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.legend-user { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + flex: 0 0 10px; + border-radius: 50%; +} + +.week-navigation { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.current-week { + min-width: 145px; + color: var(--text-muted); + font-size: .84rem; + font-weight: 700; + text-align: center; + white-space: nowrap; +} + +.nav-button { + padding: 9px 12px; + color: var(--text); + font-size: .78rem; + font-weight: 700; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition), border-color var(--transition), transform var(--transition); +} + +.nav-button:hover { + background: var(--accent-soft); + border-color: #d9bca8; +} + +.nav-button:active { + transform: translateY(1px); +} + +.schedule-table { + display: grid; + grid-template-columns: 70px repeat(7, minmax(105px, 1fr)); + min-width: 810px; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + scrollbar-color: #cbbbae transparent; + scrollbar-width: thin; +} + +.schedule-day-header { + position: sticky; + top: 0; + z-index: 3; + display: flex; + min-height: 58px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 10px 6px; + color: var(--text-muted); + font-size: .76rem; + font-weight: 700; + text-transform: capitalize; + background: var(--surface-muted); + border-right: 1px solid var(--border); + border-bottom: 2px solid #dbcfc3; + box-shadow: 0 1px 0 #dbcfc3; +} + +.schedule-day-header:first-child { + left: 0; + z-index: 4; + color: #9a8a7d; + font-size: .68rem; + letter-spacing: .04em; + text-transform: uppercase; +} + +.schedule-day-header strong { + color: var(--text); + font-size: 1.15rem; + line-height: 1; +} + +.schedule-time-axis { + position: sticky; + left: 0; + z-index: 2; + height: var(--schedule-height); + background: #f8f5f1; + border-right: 1px solid var(--border); + box-shadow: 1px 0 0 var(--border); +} + +.schedule-time-axis span { + display: block; + height: var(--hour-height); + padding: 7px 9px 0 0; + color: #998c81; + font-size: .68rem; + text-align: right; + transform: translateY(-8px); +} + +.schedule-day-column { + position: relative; + height: var(--schedule-height); + overflow: hidden; + background: var(--surface); + border-right: 1px solid var(--border); +} + +.schedule-day-column:last-child { + border-right: 0; +} + +.schedule-hour-line { + display: block; + height: var(--hour-height); + border-bottom: 1px solid #f0ebe6; +} + +.attendance-block { + position: absolute; + right: 5px; + left: 5px; + min-height: 0; + padding: 7px 8px; + overflow: hidden; + color: #633c2d; + font-size: .72rem; + line-height: 1.25; + background: var(--accent-soft); + border-left: 4px solid var(--accent); + border-radius: 7px; + box-shadow: 0 3px 8px rgba(75, 45, 26, .12); +} + +.attendance-block strong, +.attendance-block span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attendance-block strong { + color: #633c2d; +} + +.attendance-block span { + margin-top: 3px; + color: #8b5d48; + font-size: .65rem; + white-space: normal; +} + +.multi-user-schedule .attendance-bar-group { + position: absolute; + left: 50%; + display: flex; + gap: 3px; + overflow: visible; + transform: translateX(-50%); +} + +.multi-user-schedule .attendance-user-bar { + display: block; + width: 6px; + min-width: 6px; + flex: 1; + padding: 0; + border-radius: 999px; + box-shadow: 0 1px 4px rgba(52, 40, 32, .22); +} + +@media (max-width: 820px) { + .weekly-schedule { + padding: 18px 12px; + } + + .schedule-header { + display: block; + } + + .schedule-header h2 { + margin-bottom: 16px; + } + + .week-navigation { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .current-week { + grid-column: 1 / -1; + grid-row: 1; + } + + .nav-button { + width: 100%; + } + +} \ No newline at end of file diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx index e031dff..257dee1 100644 --- a/frontend/src/pages/WeeklySummaryPage.jsx +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -1,134 +1,290 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { timeApi } from '../api/time'; -import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import './WeeklySummaryPage.css'; -const WeeklySummaryPage = () => { - const [weeklySummary, setWeeklySummary] = useState(null); +const DEFAULT_START_HOUR = 8; +const DEFAULT_END_HOUR = 18; +const HOUR_HEIGHT = 64; +const USER_COLORS = ['#c56a3d', '#3d7c8c', '#8a5a9e', '#5d8b55', '#c08a32', '#b04f66', '#4772a8']; + +const WeeklySummaryPage = ({ userId }) => { + const { isAdmin } = useAuth(); + const [attendances, setAttendances] = useState([]); + const [currentWeek, setCurrentWeek] = useState(new Date()); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const navigate = useNavigate(); + const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); + const [availableScheduleHeight, setAvailableScheduleHeight] = useState(620); + const scheduleTableRef = useRef(null); useEffect(() => { - loadWeeklySummary(); + const updateViewportHeight = () => setViewportHeight(window.innerHeight); + window.addEventListener('resize', updateViewportHeight); + + return () => window.removeEventListener('resize', updateViewportHeight); }, []); - const loadWeeklySummary = async () => { - try { - setLoading(true); - const summary = await timeApi.getWeeklySummary(); - setWeeklySummary(summary); - setError(''); - } catch (err) { - setError('Erreur lors du chargement du récapitulatif hebdomadaire'); - console.error(err); - } finally { - setLoading(false); - } - }; + useLayoutEffect(() => { + const updateScheduleHeight = () => { + const scheduleTable = scheduleTableRef.current; + const main = document.querySelector('main'); + const footer = document.querySelector('footer'); - const formatTime = (timeString) => { - if (!timeString) return ''; - const [hours, minutes] = timeString.split(':'); - return `${hours}h${minutes}`; - }; + if (!scheduleTable || !main || !footer) return; - const getDayName = (dayIndex) => { - const days = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']; - return days[dayIndex]; - }; + const mainStyles = window.getComputedStyle(main); + const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0; + const footerHeight = footer.getBoundingClientRect().height; + const tableTop = scheduleTable.getBoundingClientRect().top; + const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding; - const getDayAbbreviation = (dayName) => { - const abbreviations = { - 'Monday': 'Lun', - 'Tuesday': 'Mar', - 'Wednesday': 'Mer', - 'Thursday': 'Jeu', - 'Friday': 'Ven', - 'Saturday': 'Sam', - 'Sunday': 'Dim' + setAvailableScheduleHeight(Math.max(180, remainingHeight)); + }; + + updateScheduleHeight(); + window.addEventListener('resize', updateScheduleHeight); + + return () => window.removeEventListener('resize', updateScheduleHeight); + }, [loading, error]); + + useEffect(() => { + const loadAttendances = async () => { + try { + setLoading(true); + setError(''); + const entries = await timeApi.getTimeEntries(); + setAttendances(entries); + } catch (loadError) { + setError('Impossible de charger les pointages.'); + console.error(loadError); + } finally { + setLoading(false); + } + }; + + loadAttendances(); + }, [userId]); + + const getWeekDays = (date) => { + const startOfWeek = new Date(date); + const day = startOfWeek.getDay() || 7; + startOfWeek.setDate(startOfWeek.getDate() - day + 1); + + return Array.from({ length: 7 }, (_, index) => { + const weekDay = new Date(startOfWeek); + weekDay.setDate(startOfWeek.getDate() + index); + return weekDay; + }); + }; + + const formatDateKey = (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}`; + }; + + const getTimeValue = (attendance, key) => { + const legacyKey = key === 'start_time' ? 'startTime' : 'endTime'; + const value = attendance[key] || attendance[legacyKey] || ''; + return value.slice(0, 5); + }; + + const getMinutes = (time) => { + if (!time) return null; + const [hours, minutes] = time.split(':').map(Number); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + return (hours * 60) + minutes; + }; + + const getUserColor = (username) => { + const hash = [...username].reduce((total, character) => total + character.charCodeAt(0), 0); + return USER_COLORS[hash % USER_COLORS.length]; + }; + + const getAttendancesForDay = (date) => { + const dateKey = formatDateKey(date); + return attendances.filter((attendance) => String(attendance.date).slice(0, 10) === dateKey); + }; + + const getAttendanceSegments = (dayAttendances) => { + const validAttendances = dayAttendances.map((attendance) => ({ + attendance, + start: getMinutes(getTimeValue(attendance, 'start_time')), + end: getMinutes(getTimeValue(attendance, 'end_time')), + })).filter(({ start, end }) => start !== null && end !== null && end > start); + const boundaries = [...new Set(validAttendances.flatMap(({ start, end }) => [start, end]))].sort((a, b) => a - b); + + return boundaries.slice(0, -1).map((start, index) => { + const end = boundaries[index + 1]; + const activeAttendances = validAttendances.filter(({ start: entryStart, end: entryEnd }) => ( + entryStart <= start && entryEnd >= end + )); + + if (!activeAttendances.length) return null; + + return { + start, + end, + users: activeAttendances + .map(({ attendance }) => attendance.username) + .filter(Boolean) + .filter((username, usernameIndex, names) => names.indexOf(username) === usernameIndex) + .map((username) => ({ username, color: getUserColor(username) })), + }; + }).filter(Boolean); + }; + + const navigateWeek = (direction) => { + const newWeek = new Date(currentWeek); + newWeek.setDate(currentWeek.getDate() + (direction * 7)); + setCurrentWeek(newWeek); + }; + + const getScheduleBounds = (weekDays) => { + const weekDateKeys = new Set(weekDays.map(formatDateKey)); + const weekAttendances = attendances.filter((attendance) => ( + weekDateKeys.has(String(attendance.date).slice(0, 10)) + )); + const times = weekAttendances.flatMap((attendance) => [ + getMinutes(getTimeValue(attendance, 'start_time')), + getMinutes(getTimeValue(attendance, 'end_time')), + ]).filter((time) => time !== null); + + if (!times.length) { + return { startHour: DEFAULT_START_HOUR, endHour: DEFAULT_END_HOUR }; + } + + const firstHour = Math.floor(Math.min(...times) / 60); + const lastHour = Math.ceil(Math.max(...times) / 60); + + return { + startHour: firstHour, + endHour: Math.max(lastHour, firstHour + 1), + }; + }; + + const getAttendanceStyle = (start, end, startHour, endHour, hourHeight) => { + const visibleStart = Math.max(startHour * 60, start); + const visibleEnd = Math.min(endHour * 60, end); + return { + top: `${((visibleStart - startHour * 60) / 60) * hourHeight}px`, + height: `${Math.max(((visibleEnd - visibleStart) / 60) * hourHeight, 1)}px`, }; - return abbreviations[dayName] || dayName; }; if (loading) { - return ( -
-

Récapitulatif hebdomadaire

-

Chargement...

-
- ); + return
Chargement...
; } if (error) { - return ( -
-

Récapitulatif hebdomadaire

-
{error}
-
- ); + return
{error}
; } - if (!weeklySummary) { - return ( -
-

Récapitulatif hebdomadaire

-

Aucune donnée disponible

-
- ); - } + const weekDays = getWeekDays(currentWeek); + const { startHour, endHour } = getScheduleBounds(weekDays); + const hourCount = endHour - startHour; + const measuredHeight = Math.min(availableScheduleHeight, Math.max(180, viewportHeight - 180)); + const scheduleHeight = Math.min(520, Math.max(180, measuredHeight * 0.78)); + const hourHeight = Math.min(HOUR_HEIGHT, Math.max(12, scheduleHeight / hourCount)); + const hours = Array.from({ length: hourCount }, (_, index) => startHour + index); + const formatHour = (hour) => `${String(hour).padStart(2, '0')}:00`; + const legendUsers = [...new Set(attendances.map((attendance) => attendance.username).filter(Boolean))] + .sort((first, second) => first.localeCompare(second, 'fr')) + .map((username) => ({ username, color: getUserColor(username) })); return ( -
-

Récapitulatif hebdomadaire

- -
-

{weeklySummary.username}

-

Total des heures cette semaine: {weeklySummary.total_hours}h

-
- -
-

Emploi du temps hebdomadaire

-
- {weeklySummary.days.map((day, index) => ( -
-
- {getDayName(index)} - {new Date(day.date).toLocaleDateString('fr-FR')} -
-
- {day.start_time ? ( - <> -
- Entrée: - {formatTime(day.start_time)} -
-
- Sortie: - {formatTime(day.end_time)} -
-
- Durée: - {day.duration.toFixed(2)}h -
- - ) : ( -
- Aucun pointage -
- )} -
-
- ))} +
+
+
+ {isAdmin ? 'Vue équipe' : 'Votre présence'} +

{isAdmin ? 'Récapitulatif de tous les utilisateurs' : 'Emploi du temps hebdomadaire'}

+
+
+ + + Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} + {' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} + +
-
- + {isAdmin && legendUsers.length > 0 && ( +
+ {legendUsers.map(({ username, color }) => ( + +
UtilisateurDate Entrée Sortie DuréeActionsActions
{entry.user}{formatDate(entry.date)} {formatTime(entry.start_time)} {formatTime(entry.end_time)}{entry.duration ? `${entry.duration.toFixed(2)}h` : '0.00h'} -
- - -
-
{formatDuration(entry.duration)} +
+ + +
+