diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 0691dee..8d2c9e2 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -20,12 +20,18 @@ class UserSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) + password_confirm = serializers.CharField(write_only=True) invite = serializers.UUIDField(write_only=True) class Meta: 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): try: @@ -38,6 +44,7 @@ class RegisterSerializer(serializers.ModelSerializer): def create(self, validated_data): invitation_token = validated_data.pop('invite') + validated_data.pop('password_confirm') with transaction.atomic(): invitation = Invitation.objects.select_for_update().get(token=invitation_token) if not invitation.is_valid: diff --git a/frontend/src/App.css b/frontend/src/App.css index a0e0c14..eb2d237 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,36 +1,63 @@ #root { + width: 100%; + min-width: 0; + min-height: 100dvh; +} + +.app-shell { display: flex; + width: 100%; + min-width: 0; + min-height: 100dvh; flex-direction: column; +} + +.app-shell.app--fixed { height: 100dvh; - min-height: 100vh; + overflow: hidden; +} + +.app-shell.app--scroll { + min-height: 100dvh; } main { + display: flex; + flex-direction: column; width: min(var(--content-max), 100%); + min-width: 0; min-height: 0; - flex: 1 1 auto; + flex: 1 0 auto; margin: 0 auto; - overflow-y: auto; + overflow: visible; padding: 2rem clamp(1.25rem, 3vw, 2.75rem); } .page-route { + display: flex; + flex: 1 0 auto; + flex-direction: column; width: 100%; + min-width: 0; max-width: var(--content-max); min-height: 100%; margin: 0 auto; } -main.main--bounded { +main.main--fixed { + min-height: 0; overflow: hidden; + flex: 1 1 auto; } -main.main--bounded .page-route { +main.main--fixed .page-route { height: 100%; + min-height: 0; + flex: 1 1 auto; overflow: hidden; } -main.main--flow .page-route { +main.main--scroll .page-route { min-height: 100%; } @@ -39,6 +66,7 @@ main.main--flow .page-route { .page-route > .page-container, .page-route > .weekly-schedule, .page-route > .home-page { + flex: 1 0 auto; width: 100%; max-width: none; margin-right: 0; @@ -47,10 +75,21 @@ main.main--flow .page-route { .page-container { width: 100%; + min-width: 0; max-width: var(--content-max); 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, .card { padding: clamp(1.35rem, 4vw, 2.25rem); @@ -60,24 +99,49 @@ main.main--flow .page-route { box-shadow: var(--shadow); } -.page-heading { - margin-bottom: 1.5rem; +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; } -.page-heading h2, -.page-panel > h2, -.card > h2 { +.page-header h2 { margin: 0; color: var(--text); font-size: clamp(1.35rem, 3vw, 1.8rem); letter-spacing: -0.02em; } -.page-heading p { +.page-header p { margin: 0.5rem 0 0; 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 { flex-shrink: 0; padding: 1.5rem; @@ -105,6 +169,8 @@ footer p { text-align: center; text-decoration: none; transition: var(--transition); + /* Prevent button from resizing on click */ + flex-shrink: 0; } .btn:focus-visible, @@ -154,48 +220,12 @@ input:focus-visible { border-color: var(--danger); } -.admin-toolbar { - display: flex; - flex-wrap: wrap; - gap: 0.7rem; - margin-bottom: 1.25rem; -} - .btn-group { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; 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 { margin: 0.7rem 0 0; color: var(--text-muted); @@ -332,12 +362,6 @@ tbody tr:hover { border: 1px solid #c7e5d2; } -.loading { - display: grid; - min-height: 240px; - place-items: center; -} - .spinner { width: 38px; height: 38px; @@ -353,6 +377,8 @@ tbody tr:hover { @media (max-width: 700px) { main { + width: 100%; + min-height: 0; padding: 1.25rem 1rem; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index aa21d93..987d114 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,13 +10,22 @@ import TimeClockPage from './pages/TimeClockPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage'; 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() { const { pathname } = useLocation(); - const boundedPages = ['/weekly-summary']; - const layoutMode = boundedPages.includes(pathname) ? 'bounded' : 'flow'; + const shouldScroll = pageLayout[pathname]?.scroll ?? true; + const layoutMode = shouldScroll ? 'scroll' : 'fixed'; return ( -
+
diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx index 423dfb3..efd5d31 100644 --- a/frontend/src/components/AdminRoute.jsx +++ b/frontend/src/components/AdminRoute.jsx @@ -7,7 +7,7 @@ const AdminRoute = ({ children }) => { const location = useLocation(); if (loading) { - return
Loading...
; + return
Chargement...
; } if (!user) { diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index bfbceff..6fd5471 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -7,7 +7,7 @@ const ProtectedRoute = ({ children }) => { const location = useLocation(); if (loading) { - return
Loading...
; + return
Chargement...
; } if (!user) { diff --git a/frontend/src/pages/AdminUsersPage.css b/frontend/src/pages/AdminUsersPage.css new file mode 100644 index 0000000..b0a1357 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.css @@ -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%; + } +} \ No newline at end of file diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index e3b9630..a496c63 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { userApi } from '../api/users'; - +import './AdminUsersPage.css'; const AdminUsersPage = () => { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -82,44 +82,24 @@ const AdminUsersPage = () => { if (loading) { return ( -
-

Gestion des comptes

+
+
+

Gestion des comptes

+

Chargement des utilisateurs...

); } return ( -
-

Gestion des comptes

- {error &&
{error}
} - -
- - {inviteLink && ( -
- - -
- )} -
+
+
+

Gestion des comptes

+
-
+ {error &&
{error}
} + +
@@ -163,7 +143,42 @@ const AdminUsersPage = () => { ))}
-
+ + +
+ {inviteLink ? ( +
+ + + +
+ ) : ( +
+ +
+ )} +
+
); }; diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 04c88d6..22e20dc 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -19,16 +19,18 @@ const HomePage = () => { if (loading) { return ( -
+
); } return ( -
-
+
+

Profil Utilisateur

+
+
{userData && (
@@ -45,7 +47,7 @@ const HomePage = () => {
)} -
+
); }; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 42c81af..f7ec26a 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -26,11 +26,14 @@ const LoginPage = () => { }; return ( -
+
-

Connexion

+
+

Connexion

+
{error &&
{error}
} -
+
+
{ - + +
); diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index bfd88f9..e7097b5 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -6,7 +6,13 @@ import './LoginPage.css'; const RegisterPage = () => { const invite = new URLSearchParams(useLocation().search).get('invite') || ''; 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 [isCreated, setIsCreated] = useState(false); @@ -18,28 +24,62 @@ const RegisterPage = () => { setIsCreated(true); } catch (requestError) { 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) { - return

Lien invalide

Un lien d’invitation est nécessaire pour créer un compte.

; + return ( +
+
+
+

Lien invalide

+
+

Un lien d’invitation est nécessaire pour créer un compte.

+
+
+ ); } if (isCreated) { - return

Compte créé

Votre compte est prêt. Vous pouvez maintenant vous connecter.

; + return ( +
+
+
+

Compte créé

+
+

Votre compte est prêt. Vous pouvez maintenant vous connecter.

+ +
+
+ ); } return ( -
+
-

Créer un compte

+
+

Créer un compte

+
{error &&
{error}
} -
-
setForm({ ...form, username: event.target.value })} />
-
setForm({ ...form, password: event.target.value })} />
- -
+
+
+
setForm({ ...form, first_name: event.target.value })} />
+
setForm({ ...form, last_name: event.target.value })} />
+
setForm({ ...form, username: event.target.value })} />
+
setForm({ ...form, password: event.target.value })} />
+
setForm({ ...form, password_confirm: event.target.value })} />
+ +
+
); diff --git a/frontend/src/pages/TimeClockPage.css b/frontend/src/pages/TimeClockPage.css index fb71e47..b6fd203 100644 --- a/frontend/src/pages/TimeClockPage.css +++ b/frontend/src/pages/TimeClockPage.css @@ -111,6 +111,17 @@ 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 { margin: 1.25rem 0; } @@ -159,4 +170,11 @@ grid-column: auto; grid-row: auto; } +} + +@media (max-width: 520px) { + .form-group--inline { + grid-template-columns: 1fr; + gap: 0.35rem; + } } \ No newline at end of file diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index 7968a31..5d67b37 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -321,12 +321,10 @@ const TimeClockPage = () => { }; return ( -
-
- Présence +
+

Pointage

-

Scannez le QR code présent sur votre lieu de travail pour enregistrer votre passage.

-
+
{error &&
{error}
} @@ -367,7 +365,7 @@ const TimeClockPage = () => { )} {/* Time tracking section */} -
+
{isAdmin && (
@@ -375,7 +373,7 @@ const TimeClockPage = () => {

Modifier le pointage du {formatDate(editingEntry.date)}

-
+
{ onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })} />
-
+
{

Ajouter un pointage manuel

-
+
{ required />
-
+
{ required />
-
+
{ required />
-
+
{ )}
-
+
); }; diff --git a/frontend/src/pages/WeeklySummaryPage.css b/frontend/src/pages/WeeklySummaryPage.css index 3d118d7..1c2297c 100644 --- a/frontend/src/pages/WeeklySummaryPage.css +++ b/frontend/src/pages/WeeklySummaryPage.css @@ -6,7 +6,6 @@ color: var(--text); } -.weekly-schedule.loading, .weekly-schedule.schedule-error { padding: 40px 24px; text-align: center; @@ -76,6 +75,11 @@ white-space: nowrap; } +.current-day, +.day-navigation-button { + display: none; +} + .nav-button { padding: 9px 12px; color: var(--text); @@ -257,11 +261,50 @@ } .current-week { + display: none; + } + + .current-day { + display: block; grid-column: 1 / -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 { 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; + } } \ No newline at end of file diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx index 0696d02..f7a25f0 100644 --- a/frontend/src/pages/WeeklySummaryPage.jsx +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -20,6 +20,10 @@ const WeeklySummaryPage = ({ userId }) => { const { isAdmin } = useAuth(); const [attendances, setAttendances] = useState([]); 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 [error, setError] = useState(''); const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); @@ -178,6 +182,18 @@ const WeeklySummaryPage = ({ userId }) => { 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 weekDateKeys = new Set(weekDays.map(formatDateKey)); const weekAttendances = expandOvernightAttendances(attendances).filter((attendance) => ( @@ -211,11 +227,11 @@ const WeeklySummaryPage = ({ userId }) => { }; if (loading) { - return
Chargement...
; + return
Chargement...
; } if (error) { - return
{error}
; + return
{error}
; } const weekDays = getWeekDays(currentWeek); @@ -231,44 +247,48 @@ const WeeklySummaryPage = ({ userId }) => { .map((username) => ({ username, color: getUserColor(username) })); return ( -
-
+
+
- {isAdmin ? 'Vue équipe' : 'Votre présence'}

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

- + + Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} {' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} - + + {weekDays[selectedDayIndex].toLocaleDateString('fr-FR', { weekday: 'long', day: '2-digit', month: 'short' })} + + +
-
+ {isAdmin && legendUsers.length > 0 && ( -
+
{legendUsers.map(({ username, color }) => (