This commit is contained in:
Louis Mazin 2026-09-16 00:41:35 +02:00
parent 2dcf62c597
commit 99eaacedc3
14 changed files with 368 additions and 144 deletions

View File

@ -20,12 +20,18 @@ class UserSerializer(serializers.ModelSerializer):
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
password_confirm = serializers.CharField(write_only=True)
invite = serializers.UUIDField(write_only=True) invite = serializers.UUIDField(write_only=True)
class Meta: class Meta:
model = User model = User
fields = ['username', 'password', 'first_name', 'last_name', 'invite'] fields = ['username', 'password', 'password_confirm', 'first_name', 'last_name', 'invite']
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({'password_confirm': 'Les mots de passe ne correspondent pas.'})
return attrs
def validate_invite(self, value): def validate_invite(self, value):
try: try:
@ -38,6 +44,7 @@ class RegisterSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
invitation_token = validated_data.pop('invite') invitation_token = validated_data.pop('invite')
validated_data.pop('password_confirm')
with transaction.atomic(): with transaction.atomic():
invitation = Invitation.objects.select_for_update().get(token=invitation_token) invitation = Invitation.objects.select_for_update().get(token=invitation_token)
if not invitation.is_valid: if not invitation.is_valid:

View File

@ -1,36 +1,63 @@
#root { #root {
width: 100%;
min-width: 0;
min-height: 100dvh;
}
.app-shell {
display: flex; display: flex;
width: 100%;
min-width: 0;
min-height: 100dvh;
flex-direction: column; flex-direction: column;
}
.app-shell.app--fixed {
height: 100dvh; height: 100dvh;
min-height: 100vh; overflow: hidden;
}
.app-shell.app--scroll {
min-height: 100dvh;
} }
main { main {
display: flex;
flex-direction: column;
width: min(var(--content-max), 100%); width: min(var(--content-max), 100%);
min-width: 0;
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 0 auto;
margin: 0 auto; margin: 0 auto;
overflow-y: auto; overflow: visible;
padding: 2rem clamp(1.25rem, 3vw, 2.75rem); padding: 2rem clamp(1.25rem, 3vw, 2.75rem);
} }
.page-route { .page-route {
display: flex;
flex: 1 0 auto;
flex-direction: column;
width: 100%; width: 100%;
min-width: 0;
max-width: var(--content-max); max-width: var(--content-max);
min-height: 100%; min-height: 100%;
margin: 0 auto; margin: 0 auto;
} }
main.main--bounded { main.main--fixed {
min-height: 0;
overflow: hidden; overflow: hidden;
flex: 1 1 auto;
} }
main.main--bounded .page-route { main.main--fixed .page-route {
height: 100%; height: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
main.main--flow .page-route { main.main--scroll .page-route {
min-height: 100%; min-height: 100%;
} }
@ -39,6 +66,7 @@ main.main--flow .page-route {
.page-route > .page-container, .page-route > .page-container,
.page-route > .weekly-schedule, .page-route > .weekly-schedule,
.page-route > .home-page { .page-route > .home-page {
flex: 1 0 auto;
width: 100%; width: 100%;
max-width: none; max-width: none;
margin-right: 0; margin-right: 0;
@ -47,10 +75,21 @@ main.main--flow .page-route {
.page-container { .page-container {
width: 100%; width: 100%;
min-width: 0;
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
.page-layout {
display: flex;
width: 100%;
min-width: 0;
max-width: var(--content-max);
min-height: 100%;
flex-direction: column;
gap: 1.5rem;
}
.page-panel, .page-panel,
.card { .card {
padding: clamp(1.35rem, 4vw, 2.25rem); padding: clamp(1.35rem, 4vw, 2.25rem);
@ -60,24 +99,49 @@ main.main--flow .page-route {
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.page-heading { .page-header {
margin-bottom: 1.5rem; display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
} }
.page-heading h2, .page-header h2 {
.page-panel > h2,
.card > h2 {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-size: clamp(1.35rem, 3vw, 1.8rem); font-size: clamp(1.35rem, 3vw, 1.8rem);
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.page-heading p { .page-header p {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
color: var(--text-muted); color: var(--text-muted);
} }
.page-header--stacked {
flex-direction: column;
}
.page-section {
width: 100%;
}
.page-panel > h2,
.card > h2 {
margin: 0 0 1.5rem;
color: var(--text);
font-size: clamp(1.35rem, 3vw, 1.8rem);
letter-spacing: -0.02em;
}
.page-state {
display: grid;
min-height: 240px;
place-items: center;
padding: 2rem;
text-align: center;
}
footer { footer {
flex-shrink: 0; flex-shrink: 0;
padding: 1.5rem; padding: 1.5rem;
@ -105,6 +169,8 @@ footer p {
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
transition: var(--transition); transition: var(--transition);
/* Prevent button from resizing on click */
flex-shrink: 0;
} }
.btn:focus-visible, .btn:focus-visible,
@ -154,48 +220,12 @@ input:focus-visible {
border-color: var(--danger); border-color: var(--danger);
} }
.admin-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-bottom: 1.25rem;
}
.btn-group { .btn-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.45rem; gap: 0.45rem;
} }
.invite-result {
margin-top: 1.5rem;
}
.invite-result h3,
.info-panel h3 {
margin: 0 0 0.75rem;
color: var(--text);
font-size: 1rem;
}
.invite-link-box {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem;
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.invite-link-input {
min-width: 0;
padding: 0.5rem;
color: var(--text-muted);
background: transparent;
border: 0;
}
.helper-text { .helper-text {
margin: 0.7rem 0 0; margin: 0.7rem 0 0;
color: var(--text-muted); color: var(--text-muted);
@ -332,12 +362,6 @@ tbody tr:hover {
border: 1px solid #c7e5d2; border: 1px solid #c7e5d2;
} }
.loading {
display: grid;
min-height: 240px;
place-items: center;
}
.spinner { .spinner {
width: 38px; width: 38px;
height: 38px; height: 38px;
@ -353,6 +377,8 @@ tbody tr:hover {
@media (max-width: 700px) { @media (max-width: 700px) {
main { main {
width: 100%;
min-height: 0;
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
} }

View File

@ -10,13 +10,22 @@ import TimeClockPage from './pages/TimeClockPage';
import WeeklySummaryPage from './pages/WeeklySummaryPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage';
import './App.css'; import './App.css';
const pageLayout = {
'/': { scroll: false },
'/login': { scroll: false },
'/register': { scroll: false },
'/pointage': { scroll: true },
'/users': { scroll: false },
'/weekly-summary': { scroll: false },
};
function App() { function App() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const boundedPages = ['/weekly-summary']; const shouldScroll = pageLayout[pathname]?.scroll ?? true;
const layoutMode = boundedPages.includes(pathname) ? 'bounded' : 'flow'; const layoutMode = shouldScroll ? 'scroll' : 'fixed';
return ( return (
<div id="root"> <div className={`app-shell app--${layoutMode}`}>
<Header /> <Header />
<main className={`main--${layoutMode}`}> <main className={`main--${layoutMode}`}>
<div className="page-route"> <div className="page-route">

View File

@ -7,7 +7,7 @@ const AdminRoute = ({ children }) => {
const location = useLocation(); const location = useLocation();
if (loading) { if (loading) {
return <div>Loading...</div>; return <div className="page-state">Chargement...</div>;
} }
if (!user) { if (!user) {

View File

@ -7,7 +7,7 @@ const ProtectedRoute = ({ children }) => {
const location = useLocation(); const location = useLocation();
if (loading) { if (loading) {
return <div>Loading...</div>; return <div className="page-state">Chargement...</div>;
} }
if (!user) { if (!user) {

View File

@ -0,0 +1,43 @@
.invite-result h3,
.info-panel h3 {
margin: 0 0 0.75rem;
color: var(--text);
font-size: 1rem;
}
.invite-link-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
color: var(--text-muted);
background: transparent;
border: 0;
padding: 0.7rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.admin-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.invite-actions {
display: flex;
width: 100%;
align-items: center;
gap: 0.7rem;
}
@media (max-width: 600px) {
.invite-actions {
align-items: stretch;
flex-direction: column;
}
.invite-actions .btn,
.invite-link-input {
width: 100%;
}
}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { userApi } from '../api/users'; import { userApi } from '../api/users';
import './AdminUsersPage.css';
const AdminUsersPage = () => { const AdminUsersPage = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -82,44 +82,24 @@ const AdminUsersPage = () => {
if (loading) { if (loading) {
return ( return (
<div className="card page-container admin-page"> <div className="card page-container page-layout admin-page">
<header className="page-header page-header--stacked">
<h2>Gestion des comptes</h2> <h2>Gestion des comptes</h2>
</header>
<p>Chargement des utilisateurs...</p> <p>Chargement des utilisateurs...</p>
</div> </div>
); );
} }
return ( return (
<div className="card page-container admin-page"> <div className="card page-container page-layout admin-page">
<header className="page-header page-header--stacked">
<h2>Gestion des comptes</h2> <h2>Gestion des comptes</h2>
</header>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
<div className="admin-toolbar"> <section className="page-section table-responsive">
<button
onClick={generateInviteLink}
className="btn btn-primary"
>
Inviter un utilisateur
</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 className="table-responsive">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -163,7 +143,42 @@ const AdminUsersPage = () => {
))} ))}
</tbody> </tbody>
</table> </table>
</section>
<section className="page-section">
{inviteLink ? (
<div className="invite-actions">
<button
onClick={generateInviteLink}
className="btn btn-primary"
>
Inviter un utilisateur
</button>
<input
type="text"
value={inviteLink}
readOnly
className="invite-link-input"
/>
<button
onClick={copyToClipboard}
className="btn btn-secondary"
>
{isCopied ? 'Copié!' : 'Copier'}
</button>
</div> </div>
) : (
<div className="invite-actions">
<button
onClick={generateInviteLink}
className="btn btn-primary"
>
Inviter un utilisateur
</button>
</div>
)}
</section>
</div> </div>
); );
}; };

View File

@ -19,16 +19,18 @@ const HomePage = () => {
if (loading) { if (loading) {
return ( return (
<div className="loading"> <div className="page-panel page-container page-state">
<div className="spinner"></div> <div className="spinner"></div>
</div> </div>
); );
} }
return ( return (
<div className="page-panel page-container home-page"> <div className="page-panel page-container page-layout home-page">
<div className="page-heading"> <header className="page-header page-header--stacked">
<h2>Profil Utilisateur</h2> <h2>Profil Utilisateur</h2>
</header>
<section className="page-section">
{userData && ( {userData && (
<div className="user-info"> <div className="user-info">
<div className="info-item"> <div className="info-item">
@ -45,7 +47,7 @@ const HomePage = () => {
</div> </div>
</div> </div>
)} )}
</div> </section>
</div> </div>
); );
}; };

View File

@ -26,10 +26,13 @@ const LoginPage = () => {
}; };
return ( return (
<div className="auth-page"> <div className="auth-page page-layout">
<div className="page-panel auth-card"> <div className="page-panel auth-card">
<header className="page-header page-header--stacked">
<h2>Connexion</h2> <h2>Connexion</h2>
</header>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
<section className="page-section">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label htmlFor="username">Nom d'utilisateur:</label> <label htmlFor="username">Nom d'utilisateur:</label>
@ -57,6 +60,7 @@ const LoginPage = () => {
Se connecter Se connecter
</button> </button>
</form> </form>
</section>
</div> </div>
</div> </div>
); );

View File

@ -6,7 +6,13 @@ 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: '', password: '' }); const [form, setForm] = useState({
first_name: '',
last_name: '',
username: '',
password: '',
password_confirm: '',
});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
@ -18,28 +24,62 @@ const RegisterPage = () => {
setIsCreated(true); setIsCreated(true);
} catch (requestError) { } catch (requestError) {
const detail = requestError.response?.data; const detail = requestError.response?.data;
setError(detail?.invite?.[0] || detail?.username?.[0] || 'Impossible de créer le compte.'); setError(
detail?.invite?.[0]
|| detail?.first_name?.[0]
|| detail?.last_name?.[0]
|| detail?.username?.[0]
|| detail?.password?.[0]
|| detail?.password_confirm?.[0]
|| 'Impossible de créer le compte.',
);
} }
}; };
if (!invite) { if (!invite) {
return <div className="auth-page"><div className="page-panel auth-card"><h2>Lien invalide</h2><p>Un lien d’invitation est nécessaire pour créer un compte.</p></div></div>; return (
<div className="auth-page page-layout">
<div className="page-panel auth-card">
<header className="page-header page-header--stacked">
<h2>Lien invalide</h2>
</header>
<p>Un lien d’invitation est nécessaire pour créer un compte.</p>
</div>
</div>
);
} }
if (isCreated) { if (isCreated) {
return <div className="auth-page"><div className="page-panel auth-card"><h2>Compte créé</h2><p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p><button className="btn btn-primary" onClick={() => navigate('/login')}>Se connecter</button></div></div>; return (
<div className="auth-page page-layout">
<div className="page-panel auth-card">
<header className="page-header page-header--stacked">
<h2>Compte créé</h2>
</header>
<p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p>
<button className="btn btn-primary" onClick={() => navigate('/login')}>Se connecter</button>
</div>
</div>
);
} }
return ( return (
<div className="auth-page"> <div className="auth-page page-layout">
<div className="page-panel auth-card"> <div className="page-panel auth-card">
<header className="page-header page-header--stacked">
<h2>Créer un compte</h2> <h2>Créer un compte</h2>
</header>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
<section className="page-section">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"><label htmlFor="first-name">Prénom</label><input id="first-name" required value={form.first_name} onChange={(event) => setForm({ ...form, first_name: event.target.value })} /></div>
<div className="form-group"><label htmlFor="last-name">Nom</label><input id="last-name" required value={form.last_name} onChange={(event) => setForm({ ...form, last_name: 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="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="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>
<div className="form-group"><label htmlFor="password-confirm">Confirmer le mot de passe</label><input id="password-confirm" type="password" required minLength="8" value={form.password_confirm} onChange={(event) => setForm({ ...form, password_confirm: 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>
</section>
</div> </div>
</div> </div>
); );

View File

@ -111,6 +111,17 @@
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.form-group--inline {
display: grid;
grid-template-columns: minmax(130px, 0.35fr) minmax(0, 1fr);
align-items: center;
gap: 1rem;
}
.form-group--inline label {
margin: 0;
}
.time-entries-list { .time-entries-list {
margin: 1.25rem 0; margin: 1.25rem 0;
} }
@ -160,3 +171,10 @@
grid-row: auto; grid-row: auto;
} }
} }
@media (max-width: 520px) {
.form-group--inline {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}

View File

@ -321,12 +321,10 @@ const TimeClockPage = () => {
}; };
return ( return (
<div className="card time-clock-page"> <div className="card page-layout time-clock-page">
<div className="time-clock-intro"> <header className="page-header page-header--stacked time-clock-intro">
<span className="page-eyebrow">Présence</span>
<h2>Pointage</h2> <h2>Pointage</h2>
<p>Scannez le QR code présent sur votre lieu de travail pour enregistrer votre passage.</p> </header>
</div>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
@ -367,7 +365,7 @@ const TimeClockPage = () => {
)} )}
{/* Time tracking section */} {/* Time tracking section */}
<div className="time-tracking-section"> <section className="page-section time-tracking-section">
<div className={isAdmin ? 'admin-time-layout' : undefined}> <div className={isAdmin ? 'admin-time-layout' : undefined}>
{isAdmin && ( {isAdmin && (
<div className="admin-entry-panel"> <div className="admin-entry-panel">
@ -375,7 +373,7 @@ const TimeClockPage = () => {
<div className="time-entry-form"> <div className="time-entry-form">
<h4>Modifier le pointage du {formatDate(editingEntry.date)}</h4> <h4>Modifier le pointage du {formatDate(editingEntry.date)}</h4>
<form onSubmit={handleUpdateEntry}> <form onSubmit={handleUpdateEntry}>
<div className="form-group"> <div className="form-group form-group--inline">
<label htmlFor="edit-start-time">Heure d'entrée :</label> <label htmlFor="edit-start-time">Heure d'entrée :</label>
<input <input
id="edit-start-time" id="edit-start-time"
@ -384,7 +382,7 @@ const TimeClockPage = () => {
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })} onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
/> />
</div> </div>
<div className="form-group"> <div className="form-group form-group--inline">
<label htmlFor="edit-end-time">Heure de sortie :</label> <label htmlFor="edit-end-time">Heure de sortie :</label>
<input <input
id="edit-end-time" id="edit-end-time"
@ -405,7 +403,7 @@ const TimeClockPage = () => {
<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}>
<div className="form-group"> <div className="form-group form-group--inline">
<label>Date:</label> <label>Date:</label>
<input <input
type="date" type="date"
@ -414,7 +412,7 @@ const TimeClockPage = () => {
required required
/> />
</div> </div>
<div className="form-group"> <div className="form-group form-group--inline">
<label>Date de sortie:</label> <label>Date de sortie:</label>
<input <input
type="date" type="date"
@ -424,7 +422,7 @@ const TimeClockPage = () => {
required required
/> />
</div> </div>
<div className="form-group"> <div className="form-group form-group--inline">
<label>Heure d'entrée:</label> <label>Heure d'entrée:</label>
<input <input
type="time" type="time"
@ -433,7 +431,7 @@ const TimeClockPage = () => {
required required
/> />
</div> </div>
<div className="form-group"> <div className="form-group form-group--inline">
<label>Heure de sortie:</label> <label>Heure de sortie:</label>
<input <input
type="time" type="time"
@ -505,7 +503,7 @@ const TimeClockPage = () => {
)} )}
</div> </div>
</div> </div>
</div> </section>
</div> </div>
); );
}; };

View File

@ -6,7 +6,6 @@
color: var(--text); color: var(--text);
} }
.weekly-schedule.loading,
.weekly-schedule.schedule-error { .weekly-schedule.schedule-error {
padding: 40px 24px; padding: 40px 24px;
text-align: center; text-align: center;
@ -76,6 +75,11 @@
white-space: nowrap; white-space: nowrap;
} }
.current-day,
.day-navigation-button {
display: none;
}
.nav-button { .nav-button {
padding: 9px 12px; padding: 9px 12px;
color: var(--text); color: var(--text);
@ -257,11 +261,50 @@
} }
.current-week { .current-week {
display: none;
}
.current-day {
display: block;
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 1; grid-row: 1;
color: var(--text-muted);
font-size: .9rem;
font-weight: 700;
text-align: center;
text-transform: capitalize;
}
.week-navigation-button {
display: none;
}
.day-navigation-button {
display: inline-flex;
} }
.nav-button { .nav-button {
width: 100%; width: 100%;
} }
.schedule-table {
grid-template-columns: 58px minmax(0, 1fr);
min-width: 0;
overflow-x: hidden;
}
.schedule-day-header:not(.schedule-time-header):not(.is-selected),
.schedule-day-column:not(.is-selected) {
display: none;
}
.schedule-day-header.is-selected,
.schedule-day-column.is-selected {
grid-column: 2;
}
.schedule-time-header,
.schedule-time-axis {
grid-column: 1;
}
} }

View File

@ -20,6 +20,10 @@ const WeeklySummaryPage = ({ userId }) => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [attendances, setAttendances] = useState([]); const [attendances, setAttendances] = useState([]);
const [currentWeek, setCurrentWeek] = useState(new Date()); const [currentWeek, setCurrentWeek] = useState(new Date());
const [selectedDayIndex, setSelectedDayIndex] = useState(() => {
const day = new Date().getDay();
return day === 0 ? 6 : day - 1;
});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
@ -178,6 +182,18 @@ const WeeklySummaryPage = ({ userId }) => {
setCurrentWeek(newWeek); setCurrentWeek(newWeek);
}; };
const navigateDay = (direction) => {
const nextDayIndex = selectedDayIndex + direction;
if (nextDayIndex < 0 || nextDayIndex > 6) {
navigateWeek(direction);
setSelectedDayIndex(direction < 0 ? 6 : 0);
return;
}
setSelectedDayIndex(nextDayIndex);
};
const getScheduleBounds = (weekDays) => { const getScheduleBounds = (weekDays) => {
const weekDateKeys = new Set(weekDays.map(formatDateKey)); const weekDateKeys = new Set(weekDays.map(formatDateKey));
const weekAttendances = expandOvernightAttendances(attendances).filter((attendance) => ( const weekAttendances = expandOvernightAttendances(attendances).filter((attendance) => (
@ -211,11 +227,11 @@ const WeeklySummaryPage = ({ userId }) => {
}; };
if (loading) { if (loading) {
return <div className="weekly-schedule loading">Chargement...</div>; return <div className="card page-layout page-state weekly-schedule">Chargement...</div>;
} }
if (error) { if (error) {
return <div className="weekly-schedule schedule-error">{error}</div>; return <div className="card page-layout page-state weekly-schedule schedule-error">{error}</div>;
} }
const weekDays = getWeekDays(currentWeek); const weekDays = getWeekDays(currentWeek);
@ -231,44 +247,48 @@ const WeeklySummaryPage = ({ userId }) => {
.map((username) => ({ username, color: getUserColor(username) })); .map((username) => ({ username, color: getUserColor(username) }));
return ( return (
<div className={`weekly-schedule card${isAdmin ? ' multi-user-schedule' : ''}`}> <div className={`weekly-schedule card page-layout${isAdmin ? ' multi-user-schedule' : ''}`}>
<div className="schedule-header"> <header className="page-header schedule-header">
<div> <div>
<span className="page-eyebrow">{isAdmin ? 'Vue équipe' : 'Votre présence'}</span>
<h2>{isAdmin ? 'Récapitulatif de tous les utilisateurs' : 'Emploi du temps hebdomadaire'}</h2> <h2>{isAdmin ? 'Récapitulatif de tous les utilisateurs' : 'Emploi du temps hebdomadaire'}</h2>
</div> </div>
<div className="week-navigation"> <div className="week-navigation">
<button type="button" onClick={() => navigateWeek(-1)} className="nav-button">Semaine précédente</button> <button type="button" onClick={() => navigateWeek(-1)} className="nav-button week-navigation-button">{'<'} Précédente</button>
<button type="button" onClick={() => navigateDay(-1)} className="nav-button day-navigation-button" aria-label="Jour précédent">{'<'} Jour précédent</button>
<span className="current-week"> <span className="current-week">
Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
{' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} {' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
</span> </span>
<button type="button" onClick={() => navigateWeek(1)} className="nav-button">Semaine suivante</button> <span className="current-day">
</div> {weekDays[selectedDayIndex].toLocaleDateString('fr-FR', { weekday: 'long', day: '2-digit', month: 'short' })}
</span>
<button type="button" onClick={() => navigateDay(1)} className="nav-button day-navigation-button" aria-label="Jour suivant">Jour suivant {'>'}</button>
<button type="button" onClick={() => navigateWeek(1)} className="nav-button week-navigation-button">Suivante {'>'}</button>
</div> </div>
</header>
{isAdmin && legendUsers.length > 0 && ( {isAdmin && legendUsers.length > 0 && (
<div className="schedule-legend" aria-label="Légende des utilisateurs"> <section className="page-section schedule-legend" aria-label="Légende des utilisateurs">
{legendUsers.map(({ username, color }) => ( {legendUsers.map(({ username, color }) => (
<span className="legend-user" key={username}> <span className="legend-user" key={username}>
<i className="legend-swatch" style={{ backgroundColor: color }} aria-hidden="true" /> <i className="legend-swatch" style={{ backgroundColor: color }} aria-hidden="true" />
{username} {username}
</span> </span>
))} ))}
</div> </section>
)} )}
<div <div
className="schedule-table" className="page-section schedule-table"
ref={scheduleTableRef} ref={scheduleTableRef}
style={{ style={{
'--schedule-height': `${scheduleHeight}px`, '--schedule-height': `${scheduleHeight}px`,
'--hour-height': `${hourHeight}px`, '--hour-height': `${hourHeight}px`,
}} }}
> >
<div className="schedule-day-header">Heure</div> <div className="schedule-day-header schedule-time-header">Heure</div>
{weekDays.map((day) => ( {weekDays.map((day, dayIndex) => (
<div className="schedule-day-header" key={formatDateKey(day)}> <div className={`schedule-day-header${dayIndex === selectedDayIndex ? ' is-selected' : ''}`} key={formatDateKey(day)}>
<span>{day.toLocaleDateString('fr-FR', { weekday: 'short' })}</span> <span>{day.toLocaleDateString('fr-FR', { weekday: 'short' })}</span>
<strong>{day.getDate()}</strong> <strong>{day.getDate()}</strong>
</div> </div>
@ -278,8 +298,8 @@ const WeeklySummaryPage = ({ userId }) => {
{hours.map((hour) => <span key={hour}>{formatHour(hour)}</span>)} {hours.map((hour) => <span key={hour}>{formatHour(hour)}</span>)}
</div> </div>
{weekDays.map((day) => ( {weekDays.map((day, dayIndex) => (
<div className="schedule-day-column" key={formatDateKey(day)}> <div className={`schedule-day-column${dayIndex === selectedDayIndex ? ' is-selected' : ''}`} key={formatDateKey(day)}>
{hours.map((hour) => <span className="schedule-hour-line" key={hour} />)} {hours.map((hour) => <span className="schedule-hour-line" key={hour} />)}
{isAdmin ? getAttendanceColumns(getAttendancesForDay(day)).map(({ start, end, username, color }, columnIndex) => { {isAdmin ? getAttendanceColumns(getAttendancesForDay(day)).map(({ start, end, username, color }, columnIndex) => {
const startTime = formatSegmentTime(start); const startTime = formatSegmentTime(start);
@ -316,7 +336,6 @@ const WeeklySummaryPage = ({ userId }) => {
key={`${formatDateKey(day)}-${start}-${end}`} key={`${formatDateKey(day)}-${start}-${end}`}
> >
<strong>{startTime} - {endTime}</strong> <strong>{startTime} - {endTime}</strong>
<span>{segmentLabel}</span>
</div> </div>
); );
})} })}