This commit is contained in:
Louis Mazin 2026-09-15 20:35:07 +02:00
parent f4d4cdcc14
commit 1c2ed65db8
27 changed files with 69 additions and 1353 deletions

View File

@ -1,33 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-12 17:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TimeEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('start_time', models.TimeField()),
('end_time', models.TimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-date', '-start_time'],
'unique_together': {('user', 'date', 'start_time')},
},
),
]

View File

@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('time_tracking', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='timeentry',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='timeentry',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
]

View File

@ -6,7 +6,6 @@ from .views import (
TimeEntryDetailView,
TimeEntryUpdateView,
WeeklySummaryView,
UserWeeklySummaryView
)
urlpatterns = [
@ -15,6 +14,6 @@ urlpatterns = [
path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'),
path('entries/<int:entry_id>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'),
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='weekly-summary'),
]

View File

@ -5,7 +5,7 @@ from rest_framework.views import APIView
from django.contrib.auth.models import User
from django.db import transaction
from django.utils import timezone
from datetime import datetime, date, timedelta
from datetime import timedelta
from datetime import time as datetime_time
from decouple import config
from .models import TimeEntry
@ -179,124 +179,48 @@ class TimeEntryUpdateView(APIView):
status=status.HTTP_404_NOT_FOUND
)
def build_weekly_summary(user):
today = timezone.localdate()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
entries = list(TimeEntry.objects.filter(
user=user,
date__range=(start_of_week, end_of_week),
).order_by('date'))
entries_by_date = {}
for entry in entries:
entries_by_date.setdefault(entry.date, entry)
days_data = []
for day_offset in range(7):
current_day = start_of_week + timedelta(days=day_offset)
entry = entries_by_date.get(current_day)
days_data.append({
'date': current_day.isoformat(),
'day': current_day.strftime('%A'),
'start_time': str(entry.start_time) if entry and entry.start_time else None,
'end_time': str(entry.end_time) if entry and entry.end_time else None,
'duration': entry.duration if entry else 0,
})
return {
'user': user.username,
'username': user.username,
'total_hours': round(sum(entry.duration or 0 for entry in entries), 2),
'days': days_data,
}
class WeeklySummaryView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, user_id=None):
# If user_id is provided and user is staff, get that user's summary
# Otherwise, get current user's summary
user = request.user
if user_id and (request.user.is_staff or request.user.is_superuser):
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response(
{'error': 'Utilisateur non trouvé'},
status=status.HTTP_404_NOT_FOUND
)
else:
user = request.user
return Response({'error': 'Utilisateur non trouvé'}, status=status.HTTP_404_NOT_FOUND)
# Get current week (Monday to Sunday)
today = timezone.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
# Get all time entries for the current week for this user
entries = TimeEntry.objects.filter(
user=user,
date__gte=start_of_week,
date__lte=end_of_week
).order_by('date')
# Group entries by day
days_data = []
for i in range(7):
current_day = start_of_week + timedelta(days=i)
day_entries = entries.filter(date=current_day)
if day_entries.exists():
# Get the first entry for this day (assuming one entry per day)
entry = day_entries.first()
days_data.append({
'date': entry.date.isoformat(),
'day': entry.date.strftime('%A'),
'start_time': str(entry.start_time) if entry.start_time else None,
'end_time': str(entry.end_time) if entry.end_time else None,
'duration': entry.duration
})
else:
days_data.append({
'date': current_day.isoformat(),
'day': current_day.strftime('%A'),
'start_time': None,
'end_time': None,
'duration': 0
})
total_hours = sum(entry.duration or 0 for entry in entries)
summary = {
'user': user.username,
'username': user.username,
'total_hours': round(total_hours, 2),
'days': days_data
}
serializer = WeeklySummarySerializer(summary)
return Response(serializer.data)
class UserWeeklySummaryView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
# Get current user's weekly summary
user = request.user
# Get current week (Monday to Sunday)
today = timezone.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
# Get all time entries for the current week for this user
entries = TimeEntry.objects.filter(
user=user,
date__gte=start_of_week,
date__lte=end_of_week
).order_by('date')
# Group entries by day
days_data = []
for i in range(7):
current_day = start_of_week + timedelta(days=i)
day_entries = entries.filter(date=current_day)
if day_entries.exists():
# Get the first entry for this day (assuming one entry per day)
entry = day_entries.first()
days_data.append({
'date': entry.date.isoformat(),
'day': entry.date.strftime('%A'),
'start_time': str(entry.start_time) if entry.start_time else None,
'end_time': str(entry.end_time) if entry.end_time else None,
'duration': entry.duration
})
else:
days_data.append({
'date': current_day.isoformat(),
'day': current_day.strftime('%A'),
'start_time': None,
'end_time': None,
'duration': 0
})
total_hours = sum(entry.duration or 0 for entry in entries)
summary = {
'user': user.username,
'username': user.username,
'total_hours': round(total_hours, 2),
'days': days_data
}
serializer = WeeklySummarySerializer(summary)
serializer = WeeklySummarySerializer(build_weekly_summary(user))
return Response(serializer.data)

View File

@ -1,26 +0,0 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_invitations', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -11,36 +11,6 @@ export const userApi = {
}
},
// Get a specific user by ID
getUserById: async (id) => {
try {
const response = await api.get(`/users/${id}/`);
return response.data;
} catch (error) {
throw new Error('Failed to fetch user: ' + error.message);
}
},
// Create a new user
createUser: async (userData) => {
try {
const response = await api.post('/users/', userData);
return response.data;
} catch (error) {
throw new Error('Failed to create user: ' + error.message);
}
},
// Update a user
updateUser: async (id, userData) => {
try {
const response = await api.put(`/users/${id}/`, userData);
return response.data;
} catch (error) {
throw new Error('Failed to update user: ' + error.message);
}
},
// Delete a user
deleteUser: async (id) => {
try {
@ -62,13 +32,12 @@ export const userApi = {
}
},
// Get user roles
getUserRoles: async () => {
createInvitation: async () => {
try {
const response = await api.get('/users/roles/');
const response = await api.post('/users/invitations/');
return response.data;
} catch (error) {
throw new Error('Failed to fetch user roles: ' + error.message);
throw new Error('Failed to create invitation: ' + error.message);
}
},
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
const AdminRoute = ({ children }) => {
const { user, loading, isAdmin } = useAuth();

View File

@ -1,69 +0,0 @@
.attendance-app {
min-height: 100vh;
background-color: #f0f2f5;
}
.app-header {
background: #007bff;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.app-header h1 {
margin: 0;
font-size: 1.8em;
}
.app-nav {
display: flex;
gap: 10px;
}
.nav-button {
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
font-size: 1em;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.3);
}
.nav-button.active {
background: white;
color: #007bff;
font-weight: bold;
}
.app-main {
padding: 20px;
}
@media (max-width: 768px) {
.app-header {
padding: 1rem;
flex-direction: column;
align-items: stretch;
}
.app-nav {
width: 100%;
justify-content: center;
}
.nav-button {
flex: 1;
margin: 5px;
}
}

View File

@ -1,39 +0,0 @@
import React, { useState } from 'react';
import RecapTab from './RecapTab';
import './AttendanceApp.css';
const AttendanceApp = () => {
const [activeTab, setActiveTab] = useState('recap');
const [userId] = useState(1); // ID utilisateur simulé
const renderTabContent = () => {
switch (activeTab) {
case 'recap':
return <RecapTab userId={userId} />;
default:
return <RecapTab userId={userId} />;
}
};
return (
<div className="attendance-app">
<header className="app-header">
<h1>Système de Pointage</h1>
<nav className="app-nav">
<button
className={`nav-button ${activeTab === 'recap' ? 'active' : ''}`}
onClick={() => setActiveTab('recap')}
>
Récapitulatif
</button>
</nav>
</header>
<main className="app-main">
{renderTabContent()}
</main>
</div>
);
};
export default AttendanceApp;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import './Header.css';
const Header = () => {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();

View File

@ -1,68 +0,0 @@
.recap-tab {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.recap-tab.loading {
text-align: center;
padding: 40px;
}
.user-info {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 30px;
}
.user-info h1 {
color: #333;
margin-bottom: 20px;
font-size: 2em;
}
.user-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}
.detail-item {
display: flex;
flex-direction: column;
}
.label {
font-weight: bold;
color: #555;
margin-bottom: 5px;
font-size: 0.9em;
}
.value {
color: #333;
font-size: 1.1em;
}
.schedule-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
}
@media (max-width: 768px) {
.recap-tab {
padding: 10px;
}
.user-info h1 {
font-size: 1.5em;
}
.user-details {
grid-template-columns: 1fr;
}
}

View File

@ -1,56 +0,0 @@
import React, { useState, useEffect } from 'react';
import WeeklySchedule from './WeeklySchedule';
import './RecapTab.css';
const RecapTab = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Simuler le chargement des données utilisateur
useEffect(() => {
// Ici, nous utiliserions un service pour charger les données utilisateur
// Pour l'exemple, on simule avec des données statiques
const mockUser = {
id: userId,
name: 'Jean Dupont',
email: 'jean.dupont@example.com',
department: 'Développement',
position: 'Développeur'
};
setUser(mockUser);
setLoading(false);
}, [userId]);
if (loading) {
return <div className="recap-tab loading">Chargement...</div>;
}
return (
<div className="recap-tab">
<div className="user-info">
<h1>Récapitulatif de {user.name}</h1>
<div className="user-details">
<div className="detail-item">
<span className="label">Email:</span>
<span className="value">{user.email}</span>
</div>
<div className="detail-item">
<span className="label">Département:</span>
<span className="value">{user.department}</span>
</div>
<div className="detail-item">
<span className="label">Poste:</span>
<span className="value">{user.position}</span>
</div>
</div>
</div>
<div className="schedule-section">
<WeeklySchedule userId={userId} />
</div>
</div>
);
};
export default RecapTab;

View File

@ -1,303 +0,0 @@
.time-clock-qr {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.time-clock-qr h2 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.scan-area {
margin-bottom: 30px;
}
.qr-placeholder {
width: 100%;
height: 250px;
border: 2px dashed #ccc;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #f9f9f9;
position: relative;
overflow: hidden;
}
.qr-placeholder-content {
text-align: center;
}
.qr-code {
width: 150px;
height: 150px;
background: #333;
margin: 0 auto 15px;
border-radius: 8px;
position: relative;
overflow: hidden;
}
.qr-code::before {
content: '';
position: absolute;
top: 20%;
left: 20%;
width: 60%;
height: 60%;
background: #fff;
border-radius: 4px;
}
.qr-code::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, #333 25%, transparent 25%, transparent 75%, #333 75%),
linear-gradient(45deg, #333 25%, transparent 25%, transparent 75%, #333 75%);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
}
.scanning {
text-align: center;
}
.scanner-line {
width: 100%;
height: 2px;
background: #007bff;
position: absolute;
top: 50%;
animation: scan 2s infinite;
}
@keyframes scan {
0% { transform: translateY(-50%) translateY(-50%); }
50% { transform: translateY(-50%) translateY(50%); }
100% { transform: translateY(-50%) translateY(-50%); }
}
.scan-result {
text-align: center;
}
.result-icon {
font-size: 48px;
color: #28a745;
margin-bottom: 10px;
}
.action-selection {
margin-bottom: 30px;
}
.action-selection h3 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
.action-btn {
padding: 20px 30px;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 120px;
}
.action-btn:hover {
border-color: #007bff;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 123, 255, 0.1);
}
.action-btn.selected {
border-color: #007bff;
background: #007bff;
color: white;
}
.action-icon {
font-size: 24px;
}
.action-text {
font-weight: 500;
}
.scan-button {
width: 100%;
padding: 15px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
font-size: 18px;
font-weight: 500;
cursor: pointer;
transition: background 0.3s;
}
.scan-button:hover:not(:disabled) {
background: #0056b3;
}
.scan-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.message {
margin-top: 20px;
padding: 15px;
border-radius: 8px;
text-align: center;
font-weight: 500;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.error-message {
margin-top: 15px;
padding: 10px;
background: #f8d7da;
color: #721c24;
border-radius: 5px;
text-align: center;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 10px;
max-width: 400px;
width: 90%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
.modal-content h3 {
margin-top: 0;
color: #333;
text-align: center;
}
.modal-content p {
margin-bottom: 20px;
text-align: center;
color: #666;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
.btn-cancel {
padding: 10px 20px;
background: #6c757d;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.btn-confirm {
padding: 10px 20px;
background: #28a745;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
@media (max-width: 768px) {
.time-clock-qr {
margin: 10px;
padding: 15px;
}
.action-buttons {
flex-direction: column;
align-items: center;
}
.action-btn {
width: 100%;
max-width: 200px;
}
.qr-placeholder {
height: 200px;
}
.modal-content {
padding: 20px;
}
}

View File

@ -1,203 +0,0 @@
import React, { useState, useEffect } from 'react';
import './TimeClockQR.css';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
const TimeClockQR = () => {
const { user } = useAuth();
const [scanResult, setScanResult] = useState(null);
const [isScanning, setIsScanning] = useState(false);
const [selectedAction, setSelectedAction] = useState('');
const [entryTime, setEntryTime] = useState('');
const [showEntryTimeModal, setShowEntryTimeModal] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
// Simuler le scan QR (dans une vraie application, cela serait avec une librairie de scan)
const simulateQRScan = () => {
setIsScanning(true);
setMessage('Scan en cours...');
// Simuler un scan QR après un délai
setTimeout(() => {
const mockScanResult = {
userId: user?.id || 1,
timestamp: new Date().toISOString(),
action: selectedAction
};
setScanResult(mockScanResult);
setIsScanning(false);
setMessage('Scan réussi !');
// Si c'est une sortie sans entrée, demander l'heure d'entrée
if (selectedAction === 'exit' && !hasEntryToday()) {
setShowEntryTimeModal(true);
} else {
processTimeEntry(mockScanResult);
}
}, 1500);
};
// Vérifier si l'utilisateur a déjà pointé une entrée aujourd'hui
const hasEntryToday = () => {
// Dans une vraie application, cela viendrait de l'API
return false;
};
// Traiter l'entrée de temps
const processTimeEntry = async (scanData) => {
setLoading(true);
setError('');
try {
const entryData = {
user: scanData.userId,
action: scanData.action,
timestamp: scanData.timestamp,
entry_time: selectedAction === 'exit' ? entryTime : null
};
const response = await api.post('/time/entries/', entryData);
setMessage(`Pointage ${scanData.action} enregistré avec succès !`);
setSelectedAction('');
setEntryTime('');
// Réinitialiser le scan
setTimeout(() => {
setScanResult(null);
setMessage('');
}, 3000);
} catch (err) {
setError('Erreur lors de l\'enregistrement du pointage');
console.error('Erreur de pointage:', err);
} finally {
setLoading(false);
}
};
// Gérer la soumission du formulaire d'heure d'entrée
const handleEntryTimeSubmit = (e) => {
e.preventDefault();
if (entryTime) {
setShowEntryTimeModal(false);
const scanData = {
userId: user?.id || 1,
timestamp: new Date().toISOString(),
action: 'exit'
};
processTimeEntry(scanData);
}
};
return (
<div className="time-clock-qr">
<div className="qr-scan-container">
<h2>Pointage par QR Code</h2>
<div className="scan-area">
<div className="qr-placeholder">
{isScanning ? (
<div className="scanning">
<div className="scanner-line"></div>
<p>Scan en cours...</p>
</div>
) : scanResult ? (
<div className="scan-result">
<div className="result-icon">✓</div>
<p>Scan réussi !</p>
<p>Action: {scanResult.action}</p>
</div>
) : (
<div className="qr-placeholder-content">
<div className="qr-code"></div>
<p>Positionnez le QR code dans le cadre</p>
</div>
)}
</div>
</div>
<div className="action-selection">
<h3>Sélectionnez votre action</h3>
<div className="action-buttons">
<button
className={`action-btn ${selectedAction === 'entry' ? 'selected' : ''}`}
onClick={() => setSelectedAction('entry')}
>
<span className="action-icon">▶</span>
<span className="action-text">Entrée</span>
</button>
<button
className={`action-btn ${selectedAction === 'exit' ? 'selected' : ''}`}
onClick={() => setSelectedAction('exit')}
>
<span className="action-icon">⏹</span>
<span className="action-text">Sortie</span>
</button>
</div>
</div>
<button
className="scan-button"
onClick={simulateQRScan}
disabled={!selectedAction || isScanning || loading}
>
{isScanning ? 'Scannage...' : 'Scanner le QR Code'}
</button>
{message && (
<div className={`message ${error ? 'error' : 'success'}`}>
{message}
</div>
)}
{error && (
<div className="error-message">
{error}
</div>
)}
</div>
{/* Modal pour saisir l'heure d'entrée */}
{showEntryTimeModal && (
<div className="modal-overlay">
<div className="modal-content">
<h3>Heure d'entrée requise</h3>
<p>Vous avez indiqué une sortie sans avoir pointé l'entrée.</p>
<form onSubmit={handleEntryTimeSubmit}>
<div className="form-group">
<label htmlFor="entryTime">Heure d'entrée :</label>
<input
type="time"
id="entryTime"
value={entryTime}
onChange={(e) => setEntryTime(e.target.value)}
required
/>
</div>
<div className="modal-buttons">
<button
type="button"
className="btn-cancel"
onClick={() => setShowEntryTimeModal(false)}
>
Annuler
</button>
<button
type="submit"
className="btn-confirm"
>
Valider
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default TimeClockQR;

View File

@ -1,212 +0,0 @@
.weekly-schedule { background: #fff; border: 1px solid #e7e3dc; border-radius: 14px; padding: 24px; margin: 0; color: #2b2926; }
.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;
}
.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: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
scrollbar-color: #cbbbae transparent;
scrollbar-width: thin;
}
.schedule-day-header {
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;
}
.schedule-day-header:first-child {
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 {
height: var(--schedule-height);
background: #f8f5f1;
border-right: 1px solid var(--border);
}
.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-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: 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-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%;
}
}

View File

@ -1,7 +1,6 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import { useState, useEffect } from 'react';
import api from '../api/axios';
const AuthContext = createContext(null);
import { AuthContext } from './authContextValue';
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
@ -20,7 +19,6 @@ export const AuthProvider = ({ children }) => {
const userData = response.data;
setUser(userData);
setIsAdmin(userData.is_admin || false);
setUser(userData);
} catch (error) {
console.error('Failed to verify authentication', error);
localStorage.removeItem('token');
@ -64,4 +62,3 @@ export const AuthProvider = ({ children }) => {
);
};
export const useAuth = () => useContext(AuthContext);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const AuthContext = createContext(null);

View File

@ -0,0 +1,4 @@
import { useContext } from 'react';
import { AuthContext } from './authContextValue';
export const useAuth = () => useContext(AuthContext);

View File

@ -1,75 +0,0 @@
import React, { useState } from 'react';
import api from '../api/axios';
const AdminInvitePage = () => {
const [inviteLink, setInviteLink] = useState('');
const [isCopied, setIsCopied] = useState(false);
const [error, setError] = useState('');
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);
});
}
};
return (
<div className="card admin-page">
<h2>Générer un lien d'invitation</h2>
{error && <div className="alert alert-danger">{error}</div>}
<div className="admin-toolbar">
<button
onClick={generateInviteLink}
className="btn btn-primary"
>
Générer un nouveau lien
</button>
</div>
{inviteLink && (
<div className="invite-result">
<h3>Lien d'invitation généré :</h3>
<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>
<p className="helper-text">
Partagez ce lien avec les nouveaux utilisateurs pour qu'ils puissent créer un compte.
</p>
</div>
)}
</div>
);
};
export default AdminInvitePage;

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import api from '../api/axios';
import { userApi } from '../api/users';
const AdminUsersPage = () => {
@ -12,8 +11,8 @@ const AdminUsersPage = () => {
const generateInviteLink = async () => {
try {
setError('');
const response = await api.post('/users/invitations/');
const newInviteLink = `${window.location.origin}/register?invite=${response.data.token}`;
const invitation = await userApi.createInvitation();
const newInviteLink = `${window.location.origin}/register?invite=${invitation.token}`;
setInviteLink(newInviteLink);
setIsCopied(false);
} catch (error) {
@ -38,8 +37,8 @@ const AdminUsersPage = () => {
const loadUsers = async () => {
try {
setLoading(true);
const response = await api.get('/users/');
setUsers(response.data);
const users = await userApi.getUsers();
setUsers(users);
} catch (requestError) {
setError('Erreur lors du chargement des utilisateurs');
console.error(requestError);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import './HomePage.css';
const HomePage = () => {

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import './LoginPage.css';
const LoginPage = () => {

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { BrowserQRCodeReader } from '@zxing/browser';
import './TimeClockPage.css';
import { timeApi } from '../api/time';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
const TimeClockPage = () => {
const videoRef = useRef(null);
@ -11,7 +11,6 @@ const TimeClockPage = () => {
const qrControlsRef = useRef(null);
const { isAdmin } = useAuth();
const [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState('');
const [scanMessage, setScanMessage] = useState('');
const [scanConflict, setScanConflict] = useState(null);
const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5));
@ -40,17 +39,10 @@ const TimeClockPage = () => {
return () => clearInterval(timer);
}, []);
// Load time entries when component mounts
useEffect(() => {
loadTimeEntries();
}, []);
const loadTimeEntries = async () => {
try {
setLoading(true);
const entries = await timeApi.getTimeEntries();
setTimeEntries(entries);
setError('');
} catch (err) {
setError('Erreur lors du chargement des pointages');
console.error(err);
@ -59,6 +51,11 @@ const TimeClockPage = () => {
}
};
// Load time entries when component mounts
useEffect(() => {
loadTimeEntries();
}, []);
const handleAddEntry = async (e) => {
e.preventDefault();
if (!newEntry.start_time || !newEntry.end_time) {
@ -172,7 +169,6 @@ const TimeClockPage = () => {
};
const handleQrResult = async (code) => {
setScanResult(code);
setScanConflict(null);
stopScanner();
@ -257,7 +253,6 @@ const TimeClockPage = () => {
const startScanner = async () => {
setError('');
setScanResult('');
setScanMessage('');
setScanConflict(null);

View File

@ -224,11 +224,10 @@
.multi-user-schedule .attendance-bar-group {
position: absolute;
left: 50%;
left: 5px;
display: flex;
gap: 3px;
overflow: visible;
transform: translateX(-50%);
}
.multi-user-schedule .attendance-user-bar {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { timeApi } from '../api/time';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import './WeeklySummaryPage.css';
const DEFAULT_START_HOUR = 8;
@ -45,7 +45,8 @@ const WeeklySummaryPage = ({ userId }) => {
const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0;
const footerHeight = footer.getBoundingClientRect().height;
const tableTop = scheduleTable.getBoundingClientRect().top;
const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding;
const tableHeaderHeight = 58;
const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding - tableHeaderHeight;
setAvailableScheduleHeight(Math.max(180, remainingHeight));
};
@ -199,7 +200,7 @@ const WeeklySummaryPage = ({ userId }) => {
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 scheduleHeight = Math.max(180, measuredHeight);
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`;

View File

@ -1,70 +0,0 @@
// Service pour le stockage des pointages
class AttendanceStorage {
constructor() {
this.attendances = [];
this.loadAttendances();
}
// Charger les pointages depuis le localStorage
loadAttendances() {
try {
const stored = localStorage.getItem('attendances');
if (stored) {
this.attendances = JSON.parse(stored);
}
} catch (error) {
console.error('Erreur lors du chargement des pointages:', error);
this.attendances = [];
}
}
// Sauvegarder les pointages dans le localStorage
saveAttendances() {
try {
localStorage.setItem('attendances', JSON.stringify(this.attendances));
} catch (error) {
console.error('Erreur lors de la sauvegarde des pointages:', error);
}
}
// Ajouter un nouveau pointage
addAttendance(attendance) {
this.attendances.push(attendance);
this.saveAttendances();
}
// Obtenir tous les pointages
getAllAttendances() {
return this.attendances;
}
// Obtenir les pointages pour un utilisateur spécifique
getAttendancesByUser(userId) {
return this.attendances.filter(attendance => attendance.userId === userId);
}
// Obtenir les pointages pour une période donnée
getAttendancesByPeriod(startDate, endDate) {
return this.attendances.filter(attendance => {
const attendanceDate = new Date(attendance.date);
return attendanceDate >= startDate && attendanceDate <= endDate;
});
}
// Mettre à jour un pointage
updateAttendance(id, updatedAttendance) {
const index = this.attendances.findIndex(a => a.id === id);
if (index !== -1) {
this.attendances[index] = { ...this.attendances[index], ...updatedAttendance };
this.saveAttendances();
}
}
// Supprimer un pointage
deleteAttendance(id) {
this.attendances = this.attendances.filter(attendance => attendance.id !== id);
this.saveAttendances();
}
}
export default new AttendanceStorage();