good upgrade
This commit is contained in:
parent
d6b412bce5
commit
0451ed3205
@ -126,7 +126,9 @@ STATIC_URL = 'static/'
|
|||||||
# CORS configuration
|
# CORS configuration
|
||||||
CORS_ALLOWED_ORIGINS = [
|
CORS_ALLOWED_ORIGINS = [
|
||||||
"http://localhost:1028",
|
"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
|
# REST Framework & JWT settings
|
||||||
|
|||||||
@ -11,7 +11,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
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):
|
def get_is_admin(self, obj):
|
||||||
return obj.is_superuser or obj.is_staff
|
return obj.is_superuser or obj.is_staff
|
||||||
@ -23,7 +24,8 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['username', 'password', 'email', 'first_name', 'last_name', 'invite']
|
|
||||||
|
fields = ['username', 'password', 'first_name', 'last_name', 'invite']
|
||||||
|
|
||||||
def validate_invite(self, value):
|
def validate_invite(self, value):
|
||||||
try:
|
try:
|
||||||
|
|||||||
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "app",
|
"name": "app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@ -1570,6 +1606,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@ -5,13 +5,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
width: min(1120px, 100%);
|
width: min(var(--content-max), 100%);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.5rem 4rem;
|
padding: 2rem clamp(1.25rem, 3vw, 2.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@ -176,10 +177,20 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
max-width: 900px;
|
width: 100%;
|
||||||
|
max-width: var(--content-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-page,
|
||||||
|
.weekly-summary-page,
|
||||||
|
.time-clock-page,
|
||||||
|
.profile-card,
|
||||||
|
.home-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max);
|
||||||
|
}
|
||||||
|
|
||||||
.card h2,
|
.card h2,
|
||||||
.profile-card h2 {
|
.profile-card h2 {
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
@ -294,7 +305,7 @@ tbody tr:hover {
|
|||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
main {
|
main {
|
||||||
padding: 1.25rem 1rem 2.5rem;
|
padding: 1.25rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import ProtectedRoute from './components/ProtectedRoute';
|
|||||||
import AdminRoute from './components/AdminRoute';
|
import AdminRoute from './components/AdminRoute';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage';
|
import AdminUsersPage from './pages/AdminUsersPage';
|
||||||
import AdminInvitePage from './pages/AdminInvitePage';
|
|
||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import TimeClockPage from './pages/TimeClockPage';
|
import TimeClockPage from './pages/TimeClockPage';
|
||||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||||
@ -37,11 +36,6 @@ function App() {
|
|||||||
<AdminUsersPage />
|
<AdminUsersPage />
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/invite" element={
|
|
||||||
<AdminRoute>
|
|
||||||
<AdminInvitePage />
|
|
||||||
</AdminRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/weekly-summary" element={
|
<Route path="/weekly-summary" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<WeeklySummaryPage />
|
<WeeklySummaryPage />
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Utiliser l'URL correcte pour le backend
|
const apiHost = window.location.hostname;
|
||||||
|
|
||||||
const api = axios.create({
|
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
|
// Ajouter un intercepteur pour gérer les erreurs de réseau
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
max-width: 1120px;
|
width: min(var(--content-max), 100%);
|
||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,118 +1,209 @@
|
|||||||
|
.weekly-schedule { background: #fff; border: 1px solid #e7e3dc; border-radius: 14px; padding: 24px; margin: 0; color: #2b2926; }
|
||||||
.weekly-schedule {
|
.weekly-schedule {
|
||||||
background: white;
|
margin: 0;
|
||||||
border-radius: 8px;
|
padding: 24px;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
color: var(--text);
|
||||||
padding: 20px;
|
background: var(--surface);
|
||||||
margin: 20px 0;
|
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;
|
text-align: center;
|
||||||
padding: 40px;
|
}
|
||||||
|
|
||||||
|
.schedule-error {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fff5f1;
|
||||||
|
border-color: #efcfc3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-header {
|
.schedule-header {
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-header h2 {
|
.schedule-header h2 {
|
||||||
color: #333;
|
margin: 0;
|
||||||
margin-bottom: 15px;
|
color: var(--text);
|
||||||
font-size: 1.5em;
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-navigation {
|
.week-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-week {
|
.current-week {
|
||||||
font-weight: bold;
|
min-width: 145px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
|
font-size: .84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button {
|
.nav-button {
|
||||||
background: #007bff;
|
padding: 9px 12px;
|
||||||
color: white;
|
color: var(--text);
|
||||||
border: none;
|
font-size: .78rem;
|
||||||
padding: 8px 16px;
|
font-weight: 700;
|
||||||
border-radius: 4px;
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s;
|
transition: background var(--transition), border-color var(--transition), transform var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button:hover {
|
.nav-button:hover {
|
||||||
background: #0056b3;
|
background: var(--accent-soft);
|
||||||
|
border-color: #d9bca8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-table {
|
.schedule-table {
|
||||||
width: 100%;
|
display: grid;
|
||||||
border-collapse: collapse;
|
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;
|
display: flex;
|
||||||
border-bottom: 2px solid #eee;
|
min-height: 58px;
|
||||||
font-weight: bold;
|
flex-direction: column;
|
||||||
background-color: #f8f9fa;
|
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-day-header:first-child {
|
||||||
.schedule-time-header {
|
color: #9a8a7d;
|
||||||
padding: 12px;
|
font-size: .68rem;
|
||||||
text-align: center;
|
letter-spacing: .04em;
|
||||||
flex: 1;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-row {
|
.schedule-day-header strong {
|
||||||
display: flex;
|
color: var(--text);
|
||||||
border-bottom: 1px solid #eee;
|
font-size: 1.15rem;
|
||||||
transition: background-color 0.2s;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-row:hover {
|
.schedule-time-axis {
|
||||||
background-color: #f5f5f5;
|
height: var(--schedule-height);
|
||||||
|
background: #f8f5f1;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-day,
|
.schedule-time-axis span {
|
||||||
.schedule-time {
|
display: block;
|
||||||
padding: 12px;
|
height: 64px;
|
||||||
flex: 1;
|
padding: 7px 9px 0 0;
|
||||||
text-align: center;
|
color: #998c81;
|
||||||
|
font-size: .68rem;
|
||||||
|
text-align: right;
|
||||||
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-day {
|
.schedule-day-column {
|
||||||
font-weight: 500;
|
position: relative;
|
||||||
color: #333;
|
height: var(--schedule-height);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-time {
|
.schedule-day-column:last-child {
|
||||||
color: #666;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.schedule-hour-line {
|
||||||
.schedule-header-row,
|
display: block;
|
||||||
.schedule-row {
|
height: 64px;
|
||||||
flex-direction: column;
|
border-bottom: 1px solid #f0ebe6;
|
||||||
align-items: flex-start;
|
}
|
||||||
|
|
||||||
|
.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-header {
|
||||||
.schedule-time-header,
|
display: block;
|
||||||
.schedule-day,
|
}
|
||||||
.schedule-time {
|
|
||||||
width: 100%;
|
.schedule-header h2 {
|
||||||
text-align: left;
|
margin-bottom: 16px;
|
||||||
padding: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-navigation {
|
.week-navigation {
|
||||||
flex-direction: column;
|
display: grid;
|
||||||
align-items: stretch;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-week {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button {
|
.nav-button {
|
||||||
|
|||||||
@ -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 <div className="weekly-schedule loading">Chargement...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekDays = getWeekDays(currentWeek);
|
|
||||||
const weekDates = weekDays.map(day => formatDate(day));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="weekly-schedule">
|
|
||||||
<div className="schedule-header">
|
|
||||||
<h2>Emploi du temps hebdomadaire</h2>
|
|
||||||
<div className="week-navigation">
|
|
||||||
<button onClick={() => navigateWeek(-1)} className="nav-button">Semaine précédente</button>
|
|
||||||
<span className="current-week">
|
|
||||||
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' })}
|
|
||||||
</span>
|
|
||||||
<button onClick={() => navigateWeek(1)} className="nav-button">Semaine suivante</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="schedule-table">
|
|
||||||
<div className="schedule-header-row">
|
|
||||||
<div className="schedule-day-header">Jour</div>
|
|
||||||
<div className="schedule-time-header">Heures de présence</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{weekDays.map((day, index) => (
|
|
||||||
<div key={index} className="schedule-row">
|
|
||||||
<div className="schedule-day">{weekDates[index]}</div>
|
|
||||||
<div className="schedule-time">{getHoursForDay(day)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WeeklySchedule;
|
|
||||||
@ -15,6 +15,7 @@
|
|||||||
--radius: 16px;
|
--radius: 16px;
|
||||||
--radius-sm: 10px;
|
--radius-sm: 10px;
|
||||||
--transition: 180ms ease;
|
--transition: 180ms ease;
|
||||||
|
--content-max: 1440px;
|
||||||
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
|
|||||||
@ -68,16 +68,6 @@ const AdminInvitePage = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="info-panel">
|
|
||||||
<h3>Comment cela fonctionne-t-il ?</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Les utilisateurs doivent utiliser un lien d'invitation pour créer un compte</li>
|
|
||||||
<li>Cela permet de contrôler qui peut accéder à l'application</li>
|
|
||||||
<li>Les nouveaux comptes sont créés avec des permissions standard</li>
|
|
||||||
<li>Vous pouvez promouvoir des utilisateurs en administrateurs depuis la page de gestion</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { userApi } from '../api/users';
|
import { userApi } from '../api/users';
|
||||||
|
|
||||||
@ -7,7 +6,33 @@ const AdminUsersPage = () => {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
@ -71,9 +96,28 @@ const AdminUsersPage = () => {
|
|||||||
{error && <div className="alert alert-danger">{error}</div>}
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/invite')}>
|
<button
|
||||||
Inviter quelqu'un
|
onClick={generateInviteLink}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Inviter un utilisateur
|
||||||
</button>
|
</button>
|
||||||
|
{inviteLink && (
|
||||||
|
<div className="invite-link-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteLink}
|
||||||
|
readOnly
|
||||||
|
className="invite-link-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{isCopied ? 'Copié!' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@ -81,7 +125,6 @@ const AdminUsersPage = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom d'utilisateur</th>
|
<th>Nom d'utilisateur</th>
|
||||||
<th>Email</th>
|
|
||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>Inscrit le</th>
|
<th>Inscrit le</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@ -91,7 +134,6 @@ const AdminUsersPage = () => {
|
|||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td>{user.username}</td>
|
<td>{user.username}</td>
|
||||||
<td>{user.email}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{user.is_admin ? (
|
{user.is_admin ? (
|
||||||
<span style={{ color: 'var(--accent)', fontWeight: '500' }}>Administrateur</span>
|
<span style={{ color: 'var(--accent)', fontWeight: '500' }}>Administrateur</span>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
.home-container {
|
.home-container {
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
max-width: 720px;
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
|||||||
@ -35,17 +35,13 @@ const HomePage = () => {
|
|||||||
<strong>Nom d'utilisateur:</strong>
|
<strong>Nom d'utilisateur:</strong>
|
||||||
<span>{userData.username}</span>
|
<span>{userData.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="info-item">
|
|
||||||
<strong>Email:</strong>
|
|
||||||
<span>{userData.email || 'Non spécifié'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<strong>Admin:</strong>
|
<strong>Admin:</strong>
|
||||||
<span>{userData.is_admin ? 'Oui' : 'Non'}</span>
|
<span>{userData.is_admin ? 'Oui' : 'Non'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<strong>Date d'inscription:</strong>
|
<strong>Date d'inscription:</strong>
|
||||||
<span>{userData.date_joined || 'Non spécifié'}</span>
|
<span>{new Date(user.date_joined).toLocaleDateString('fr-FR') || 'Non spécifié'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import './LoginPage.css';
|
|||||||
const RegisterPage = () => {
|
const RegisterPage = () => {
|
||||||
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
|
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form, setForm] = useState({ username: '', email: '', password: '' });
|
const [form, setForm] = useState({ username: '', password: '' });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isCreated, setIsCreated] = useState(false);
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
|
||||||
@ -37,7 +37,6 @@ const RegisterPage = () => {
|
|||||||
{error && <div className="alert alert-danger">{error}</div>}
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group"><label htmlFor="username">Nom d'utilisateur</label><input id="username" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></div>
|
<div className="form-group"><label htmlFor="username">Nom d'utilisateur</label><input id="username" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></div>
|
||||||
<div className="form-group"><label htmlFor="email">Email</label><input id="email" type="email" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></div>
|
|
||||||
<div className="form-group"><label htmlFor="password">Mot de passe</label><input id="password" type="password" required minLength="8" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} /></div>
|
<div className="form-group"><label htmlFor="password">Mot de passe</label><input id="password" type="password" required minLength="8" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} /></div>
|
||||||
<button type="submit" className="btn btn-primary">Créer mon compte</button>
|
<button type="submit" className="btn btn-primary">Créer mon compte</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
.time-clock-page {
|
.time-clock-page {
|
||||||
max-width: 640px;
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-clock-intro {
|
.time-clock-intro {
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { BrowserQRCodeReader } from '@zxing/browser';
|
||||||
import './TimeClockPage.css';
|
import './TimeClockPage.css';
|
||||||
import { timeApi } from '../api/time';
|
import { timeApi } from '../api/time';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
const TimeClockPage = () => {
|
const TimeClockPage = () => {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const streamRef = useRef(null);
|
const streamRef = useRef(null);
|
||||||
const scanFrameRef = useRef(null);
|
const scanFrameRef = useRef(null);
|
||||||
|
const qrControlsRef = useRef(null);
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [scanResult, setScanResult] = useState('');
|
const [scanResult, setScanResult] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -116,6 +120,8 @@ const TimeClockPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopScanner = () => {
|
const stopScanner = () => {
|
||||||
|
qrControlsRef.current?.stop();
|
||||||
|
qrControlsRef.current = null;
|
||||||
if (scanFrameRef.current) {
|
if (scanFrameRef.current) {
|
||||||
cancelAnimationFrame(scanFrameRef.current);
|
cancelAnimationFrame(scanFrameRef.current);
|
||||||
scanFrameRef.current = null;
|
scanFrameRef.current = null;
|
||||||
@ -134,45 +140,31 @@ const TimeClockPage = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
setScanResult('');
|
setScanResult('');
|
||||||
|
|
||||||
if (!('BarcodeDetector' in window)) {
|
if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||||
setError('La lecture QR n’est pas prise en charge par ce navigateur. Utilisez Chrome ou Edge à jour.');
|
setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supportedFormats = await window.BarcodeDetector.getSupportedFormats();
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
if (!supportedFormats.includes('qr_code')) {
|
setError('Ce navigateur ne permet pas l’accès à la caméra.');
|
||||||
setError('La lecture des QR codes n’est pas disponible sur cet appareil.');
|
|
||||||
return;
|
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);
|
setIsScanning(true);
|
||||||
|
const reader = new BrowserQRCodeReader();
|
||||||
const detector = new window.BarcodeDetector({ formats: ['qr_code'] });
|
qrControlsRef.current = await reader.decodeFromConstraints(
|
||||||
const scan = async () => {
|
{ video: { facingMode: { ideal: 'environment' } }, audio: false },
|
||||||
if (!videoRef.current || videoRef.current.readyState < 2) {
|
videoRef.current,
|
||||||
scanFrameRef.current = requestAnimationFrame(scan);
|
(result) => {
|
||||||
return;
|
if (result) {
|
||||||
}
|
setScanResult(result.getText());
|
||||||
|
stopScanner();
|
||||||
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);
|
|
||||||
} catch (scannerError) {
|
} 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);
|
console.error(scannerError);
|
||||||
stopScanner();
|
stopScanner();
|
||||||
}
|
}
|
||||||
@ -189,8 +181,12 @@ const TimeClockPage = () => {
|
|||||||
return date.toLocaleDateString('fr-FR');
|
return date.toLocaleDateString('fr-FR');
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateTotalHours = () => {
|
const formatDuration = (duration) => {
|
||||||
return timeEntries.reduce((total, entry) => total + (entry.duration || 0), 0);
|
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 (
|
return (
|
||||||
@ -211,7 +207,7 @@ const TimeClockPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-primary point-button" onClick={startScanner}>
|
<button type="button" className="btn btn-primary point-button" onClick={startScanner}>
|
||||||
Pointer avec un QR code
|
{isAdmin ? "Pointer avec un QR code" : "Pointer"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -231,7 +227,7 @@ const TimeClockPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add new entry form */}
|
{/* Add new entry form */}
|
||||||
{user.is_admin ? (
|
{isAdmin && (
|
||||||
<div className="time-entry-form">
|
<div className="time-entry-form">
|
||||||
<h4>Ajouter un pointage manuel</h4>
|
<h4>Ajouter un pointage manuel</h4>
|
||||||
<form onSubmit={handleAddEntry}>
|
<form onSubmit={handleAddEntry}>
|
||||||
@ -265,10 +261,7 @@ const TimeClockPage = () => {
|
|||||||
<button type="submit" className="btn btn-primary">Ajouter le pointage</button>
|
<button type="submit" className="btn btn-primary">Ajouter le pointage</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
</>
|
|
||||||
) }
|
|
||||||
|
|
||||||
{/* Time entries list */}
|
{/* Time entries list */}
|
||||||
<div className="time-entries-list">
|
<div className="time-entries-list">
|
||||||
@ -282,36 +275,42 @@ const TimeClockPage = () => {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{isAdmin && <th>Utilisateur</th>}
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Entrée</th>
|
<th>Entrée</th>
|
||||||
<th>Sortie</th>
|
<th>Sortie</th>
|
||||||
<th>Durée</th>
|
<th>Durée</th>
|
||||||
<th>Actions</th>
|
{isAdmin && <th>Actions</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{timeEntries.map((entry) => (
|
{timeEntries.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
|
{isAdmin &&
|
||||||
|
<td>{entry.user}</td>
|
||||||
|
}
|
||||||
<td>{formatDate(entry.date)}</td>
|
<td>{formatDate(entry.date)}</td>
|
||||||
<td>{formatTime(entry.start_time)}</td>
|
<td>{formatTime(entry.start_time)}</td>
|
||||||
<td>{formatTime(entry.end_time)}</td>
|
<td>{formatTime(entry.end_time)}</td>
|
||||||
<td>{entry.duration ? `${entry.duration.toFixed(2)}h` : '0.00h'}</td>
|
<td>{formatDuration(entry.duration)}</td>
|
||||||
<td>
|
{isAdmin &&
|
||||||
<div className="btn-group">
|
<td>
|
||||||
<button
|
<div className="btn-group">
|
||||||
onClick={() => handleEditEntry(entry)}
|
<button
|
||||||
className="btn btn-secondary btn-small"
|
onClick={() => handleEditEntry(entry)}
|
||||||
>
|
className="btn btn-secondary btn-small"
|
||||||
Modifier
|
>
|
||||||
</button>
|
Modifier
|
||||||
<button
|
</button>
|
||||||
onClick={() => handleDeleteEntry(entry.id)}
|
<button
|
||||||
className="btn btn-danger btn-small"
|
onClick={() => handleDeleteEntry(entry.id)}
|
||||||
>
|
className="btn btn-danger btn-small"
|
||||||
Supprimer
|
>
|
||||||
</button>
|
Supprimer
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -319,11 +318,6 @@ const TimeClockPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total hours */}
|
|
||||||
<div className="total-hours">
|
|
||||||
<h4>Total des heures: {calculateTotalHours().toFixed(2)}h</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
271
frontend/src/pages/WeeklySummaryPage.css
Normal file
271
frontend/src/pages/WeeklySummaryPage.css
Normal file
@ -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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,134 +1,290 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { timeApi } from '../api/time';
|
import { timeApi } from '../api/time';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import './WeeklySummaryPage.css';
|
||||||
|
|
||||||
const WeeklySummaryPage = () => {
|
const DEFAULT_START_HOUR = 8;
|
||||||
const [weeklySummary, setWeeklySummary] = useState(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const navigate = useNavigate();
|
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
|
||||||
|
const [availableScheduleHeight, setAvailableScheduleHeight] = useState(620);
|
||||||
|
const scheduleTableRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWeeklySummary();
|
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||||
|
window.addEventListener('resize', updateViewportHeight);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', updateViewportHeight);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadWeeklySummary = async () => {
|
useLayoutEffect(() => {
|
||||||
try {
|
const updateScheduleHeight = () => {
|
||||||
setLoading(true);
|
const scheduleTable = scheduleTableRef.current;
|
||||||
const summary = await timeApi.getWeeklySummary();
|
const main = document.querySelector('main');
|
||||||
setWeeklySummary(summary);
|
const footer = document.querySelector('footer');
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
|
||||||
setError('Erreur lors du chargement du récapitulatif hebdomadaire');
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timeString) => {
|
if (!scheduleTable || !main || !footer) return;
|
||||||
if (!timeString) return '';
|
|
||||||
const [hours, minutes] = timeString.split(':');
|
|
||||||
return `${hours}h${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDayName = (dayIndex) => {
|
const mainStyles = window.getComputedStyle(main);
|
||||||
const days = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'];
|
const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0;
|
||||||
return days[dayIndex];
|
const footerHeight = footer.getBoundingClientRect().height;
|
||||||
};
|
const tableTop = scheduleTable.getBoundingClientRect().top;
|
||||||
|
const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding;
|
||||||
|
|
||||||
const getDayAbbreviation = (dayName) => {
|
setAvailableScheduleHeight(Math.max(180, remainingHeight));
|
||||||
const abbreviations = {
|
};
|
||||||
'Monday': 'Lun',
|
|
||||||
'Tuesday': 'Mar',
|
updateScheduleHeight();
|
||||||
'Wednesday': 'Mer',
|
window.addEventListener('resize', updateScheduleHeight);
|
||||||
'Thursday': 'Jeu',
|
|
||||||
'Friday': 'Ven',
|
return () => window.removeEventListener('resize', updateScheduleHeight);
|
||||||
'Saturday': 'Sam',
|
}, [loading, error]);
|
||||||
'Sunday': 'Dim'
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return <div className="weekly-schedule loading">Chargement...</div>;
|
||||||
<div className="card weekly-summary-page">
|
|
||||||
<h2>Récapitulatif hebdomadaire</h2>
|
|
||||||
<p>Chargement...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <div className="weekly-schedule schedule-error">{error}</div>;
|
||||||
<div className="card weekly-summary-page">
|
|
||||||
<h2>Récapitulatif hebdomadaire</h2>
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!weeklySummary) {
|
const weekDays = getWeekDays(currentWeek);
|
||||||
return (
|
const { startHour, endHour } = getScheduleBounds(weekDays);
|
||||||
<div className="card weekly-summary-page">
|
const hourCount = endHour - startHour;
|
||||||
<h2>Récapitulatif hebdomadaire</h2>
|
const measuredHeight = Math.min(availableScheduleHeight, Math.max(180, viewportHeight - 180));
|
||||||
<p>Aucune donnée disponible</p>
|
const scheduleHeight = Math.min(520, Math.max(180, measuredHeight * 0.78));
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="card weekly-summary-page">
|
<div className={`weekly-schedule${isAdmin ? ' multi-user-schedule' : ''}`}>
|
||||||
<h2>Récapitulatif hebdomadaire</h2>
|
<div className="schedule-header">
|
||||||
|
<div>
|
||||||
<div className="weekly-summary-header">
|
<span className="page-eyebrow">{isAdmin ? 'Vue équipe' : 'Votre présence'}</span>
|
||||||
<h3>{weeklySummary.username}</h3>
|
<h2>{isAdmin ? 'Récapitulatif de tous les utilisateurs' : 'Emploi du temps hebdomadaire'}</h2>
|
||||||
<p>Total des heures cette semaine: <strong>{weeklySummary.total_hours}h</strong></p>
|
</div>
|
||||||
</div>
|
<div className="week-navigation">
|
||||||
|
<button type="button" onClick={() => navigateWeek(-1)} className="nav-button">Semaine précédente</button>
|
||||||
<div className="weekly-schedule">
|
<span className="current-week">
|
||||||
<h3>Emploi du temps hebdomadaire</h3>
|
Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
|
||||||
<div className="schedule-grid">
|
{' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
|
||||||
{weeklySummary.days.map((day, index) => (
|
</span>
|
||||||
<div key={index} className="schedule-day">
|
<button type="button" onClick={() => navigateWeek(1)} className="nav-button">Semaine suivante</button>
|
||||||
<div className="day-header">
|
|
||||||
<span className="day-name">{getDayName(index)}</span>
|
|
||||||
<span className="day-date">{new Date(day.date).toLocaleDateString('fr-FR')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="day-content">
|
|
||||||
{day.start_time ? (
|
|
||||||
<>
|
|
||||||
<div className="time-entry">
|
|
||||||
<span className="time-label">Entrée:</span>
|
|
||||||
<span className="time-value">{formatTime(day.start_time)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="time-entry">
|
|
||||||
<span className="time-label">Sortie:</span>
|
|
||||||
<span className="time-value">{formatTime(day.end_time)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="time-entry">
|
|
||||||
<span className="time-label">Durée:</span>
|
|
||||||
<span className="time-value">{day.duration.toFixed(2)}h</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="no-entry">
|
|
||||||
<span className="no-entry-text">Aucun pointage</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="weekly-summary-footer">
|
{isAdmin && legendUsers.length > 0 && (
|
||||||
<button
|
<div className="schedule-legend" aria-label="Légende des utilisateurs">
|
||||||
className="btn btn-primary"
|
{legendUsers.map(({ username, color }) => (
|
||||||
onClick={() => navigate('/time-clock')}
|
<span className="legend-user" key={username}>
|
||||||
>
|
<i className="legend-swatch" style={{ backgroundColor: color }} aria-hidden="true" />
|
||||||
Gérer les pointages
|
{username}
|
||||||
</button>
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="schedule-table"
|
||||||
|
ref={scheduleTableRef}
|
||||||
|
style={{
|
||||||
|
'--schedule-height': `${scheduleHeight}px`,
|
||||||
|
'--hour-height': `${hourHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="schedule-day-header">Heure</div>
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<div className="schedule-day-header" key={formatDateKey(day)}>
|
||||||
|
<span>{day.toLocaleDateString('fr-FR', { weekday: 'short' })}</span>
|
||||||
|
<strong>{day.getDate()}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="schedule-time-axis">
|
||||||
|
{hours.map((hour) => <span key={hour}>{formatHour(hour)}</span>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<div className="schedule-day-column" key={formatDateKey(day)}>
|
||||||
|
{hours.map((hour) => <span className="schedule-hour-line" key={hour} />)}
|
||||||
|
{getAttendanceSegments(getAttendancesForDay(day)).map(({ start, end, users }) => {
|
||||||
|
const startTime = `${String(Math.floor(start / 60)).padStart(2, '0')}:${String(start % 60).padStart(2, '0')}`;
|
||||||
|
const endTime = `${String(Math.floor(end / 60)).padStart(2, '0')}:${String(end % 60).padStart(2, '0')}`;
|
||||||
|
const segmentLabel = users.map(({ username }) => username).join(', ');
|
||||||
|
return (
|
||||||
|
isAdmin ? (
|
||||||
|
<div
|
||||||
|
className="attendance-bar-group"
|
||||||
|
style={{
|
||||||
|
...getAttendanceStyle(start, end, startHour, endHour, hourHeight),
|
||||||
|
width: `${(users.length * 6) + ((users.length - 1) * 3)}px`,
|
||||||
|
}}
|
||||||
|
title={`${startTime} - ${endTime}${segmentLabel ? ` : ${segmentLabel}` : ''}`}
|
||||||
|
aria-label={`${startTime} - ${endTime}${segmentLabel ? ` : ${segmentLabel}` : ''}`}
|
||||||
|
key={`${formatDateKey(day)}-${start}-${end}`}
|
||||||
|
>
|
||||||
|
{users.map(({ username, color }) => (
|
||||||
|
<span
|
||||||
|
className="attendance-user-bar"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
title={`${username} : ${startTime} - ${endTime}`}
|
||||||
|
aria-label={`${username} : ${startTime} - ${endTime}`}
|
||||||
|
key={username}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="attendance-block"
|
||||||
|
style={getAttendanceStyle(start, end, startHour, endHour, hourHeight)}
|
||||||
|
key={`${formatDateKey(day)}-${start}-${end}`}
|
||||||
|
>
|
||||||
|
<strong>{startTime} - {endTime}</strong>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user