clean
This commit is contained in:
parent
2dcf62c597
commit
99eaacedc3
@ -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:
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
43
frontend/src/pages/AdminUsersPage.css
Normal file
43
frontend/src/pages/AdminUsersPage.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
<h2>Gestion des comptes</h2>
|
<header className="page-header page-header--stacked">
|
||||||
|
<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">
|
||||||
<h2>Gestion des comptes</h2>
|
<header className="page-header page-header--stacked">
|
||||||
|
<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>
|
||||||
</div>
|
</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 className="invite-actions">
|
||||||
|
<button
|
||||||
|
onClick={generateInviteLink}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Inviter un utilisateur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,11 +26,14 @@ 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">
|
||||||
<h2>Connexion</h2>
|
<header className="page-header page-header--stacked">
|
||||||
|
<h2>Connexion</h2>
|
||||||
|
</header>
|
||||||
{error && <div className="alert alert-danger">{error}</div>}
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
<form onSubmit={handleSubmit}>
|
<section className="page-section">
|
||||||
|
<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>
|
||||||
<input
|
<input
|
||||||
@ -56,7 +59,8 @@ const LoginPage = () => {
|
|||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Se connecter
|
Se connecter
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
<h2>Créer un compte</h2>
|
<header className="page-header page-header--stacked">
|
||||||
|
<h2>Créer un compte</h2>
|
||||||
|
</header>
|
||||||
{error && <div className="alert alert-danger">{error}</div>}
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
<form onSubmit={handleSubmit}>
|
<section className="page-section">
|
||||||
<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>
|
<form onSubmit={handleSubmit}>
|
||||||
<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="first-name">Prénom</label><input id="first-name" required value={form.first_name} onChange={(event) => setForm({ ...form, first_name: event.target.value })} /></div>
|
||||||
<button type="submit" className="btn btn-primary">Créer mon compte</button>
|
<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>
|
||||||
</form>
|
<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-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>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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">
|
||||||
|
{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>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user