clean
This commit is contained in:
parent
2dcf62c597
commit
99eaacedc3
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div id="root">
|
||||
<div className={`app-shell app--${layoutMode}`}>
|
||||
<Header />
|
||||
<main className={`main--${layoutMode}`}>
|
||||
<div className="page-route">
|
||||
|
||||
@ -7,7 +7,7 @@ const AdminRoute = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
return <div className="page-state">Chargement...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -7,7 +7,7 @@ const ProtectedRoute = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
return <div className="page-state">Chargement...</div>;
|
||||
}
|
||||
|
||||
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 { 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 (
|
||||
<div className="card page-container admin-page">
|
||||
<h2>Gestion des comptes</h2>
|
||||
<div className="card page-container page-layout admin-page">
|
||||
<header className="page-header page-header--stacked">
|
||||
<h2>Gestion des comptes</h2>
|
||||
</header>
|
||||
<p>Chargement des utilisateurs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card page-container admin-page">
|
||||
<h2>Gestion des comptes</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<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="card page-container page-layout admin-page">
|
||||
<header className="page-header page-header--stacked">
|
||||
<h2>Gestion des comptes</h2>
|
||||
</header>
|
||||
|
||||
<div className="table-responsive">
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
<section className="page-section table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -163,7 +143,42 @@ const AdminUsersPage = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -19,16 +19,18 @@ const HomePage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="page-panel page-container page-state">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-panel page-container home-page">
|
||||
<div className="page-heading">
|
||||
<div className="page-panel page-container page-layout home-page">
|
||||
<header className="page-header page-header--stacked">
|
||||
<h2>Profil Utilisateur</h2>
|
||||
</header>
|
||||
<section className="page-section">
|
||||
{userData && (
|
||||
<div className="user-info">
|
||||
<div className="info-item">
|
||||
@ -45,7 +47,7 @@ const HomePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -26,11 +26,14 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-page page-layout">
|
||||
<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>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<section className="page-section">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Nom d'utilisateur:</label>
|
||||
<input
|
||||
@ -56,7 +59,8 @@ const LoginPage = () => {
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 <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) {
|
||||
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 (
|
||||
<div className="auth-page">
|
||||
<div className="auth-page page-layout">
|
||||
<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>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group"><label htmlFor="username">Nom d'utilisateur</label><input id="username" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></div>
|
||||
<div className="form-group"><label htmlFor="password">Mot de passe</label><input id="password" type="password" required minLength="8" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} /></div>
|
||||
<button type="submit" className="btn btn-primary">Créer mon compte</button>
|
||||
</form>
|
||||
<section className="page-section">
|
||||
<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="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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -321,12 +321,10 @@ const TimeClockPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card time-clock-page">
|
||||
<div className="time-clock-intro">
|
||||
<span className="page-eyebrow">Présence</span>
|
||||
<div className="card page-layout time-clock-page">
|
||||
<header className="page-header page-header--stacked time-clock-intro">
|
||||
<h2>Pointage</h2>
|
||||
<p>Scannez le QR code présent sur votre lieu de travail pour enregistrer votre passage.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
@ -367,7 +365,7 @@ const TimeClockPage = () => {
|
||||
)}
|
||||
|
||||
{/* Time tracking section */}
|
||||
<div className="time-tracking-section">
|
||||
<section className="page-section time-tracking-section">
|
||||
<div className={isAdmin ? 'admin-time-layout' : undefined}>
|
||||
{isAdmin && (
|
||||
<div className="admin-entry-panel">
|
||||
@ -375,7 +373,7 @@ const TimeClockPage = () => {
|
||||
<div className="time-entry-form">
|
||||
<h4>Modifier le pointage du {formatDate(editingEntry.date)}</h4>
|
||||
<form onSubmit={handleUpdateEntry}>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label htmlFor="edit-start-time">Heure d'entrée :</label>
|
||||
<input
|
||||
id="edit-start-time"
|
||||
@ -384,7 +382,7 @@ const TimeClockPage = () => {
|
||||
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label htmlFor="edit-end-time">Heure de sortie :</label>
|
||||
<input
|
||||
id="edit-end-time"
|
||||
@ -405,7 +403,7 @@ const TimeClockPage = () => {
|
||||
<div className="time-entry-form">
|
||||
<h4>Ajouter un pointage manuel</h4>
|
||||
<form onSubmit={handleAddEntry}>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label>Date:</label>
|
||||
<input
|
||||
type="date"
|
||||
@ -414,7 +412,7 @@ const TimeClockPage = () => {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label>Date de sortie:</label>
|
||||
<input
|
||||
type="date"
|
||||
@ -424,7 +422,7 @@ const TimeClockPage = () => {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label>Heure d'entrée:</label>
|
||||
<input
|
||||
type="time"
|
||||
@ -433,7 +431,7 @@ const TimeClockPage = () => {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group--inline">
|
||||
<label>Heure de sortie:</label>
|
||||
<input
|
||||
type="time"
|
||||
@ -505,7 +503,7 @@ const TimeClockPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 <div className="weekly-schedule loading">Chargement...</div>;
|
||||
return <div className="card page-layout page-state weekly-schedule">Chargement...</div>;
|
||||
}
|
||||
|
||||
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);
|
||||
@ -231,44 +247,48 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
.map((username) => ({ username, color: getUserColor(username) }));
|
||||
|
||||
return (
|
||||
<div className={`weekly-schedule card${isAdmin ? ' multi-user-schedule' : ''}`}>
|
||||
<div className="schedule-header">
|
||||
<div className={`weekly-schedule card page-layout${isAdmin ? ' multi-user-schedule' : ''}`}>
|
||||
<header className="page-header schedule-header">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
Du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
|
||||
{' '}au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{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 }) => (
|
||||
<span className="legend-user" key={username}>
|
||||
<i className="legend-swatch" style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="schedule-table"
|
||||
className="page-section schedule-table"
|
||||
ref={scheduleTableRef}
|
||||
style={{
|
||||
'--schedule-height': `${scheduleHeight}px`,
|
||||
'--hour-height': `${hourHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="schedule-day-header">Heure</div>
|
||||
{weekDays.map((day) => (
|
||||
<div className="schedule-day-header" key={formatDateKey(day)}>
|
||||
<div className="schedule-day-header schedule-time-header">Heure</div>
|
||||
{weekDays.map((day, dayIndex) => (
|
||||
<div className={`schedule-day-header${dayIndex === selectedDayIndex ? ' is-selected' : ''}`} key={formatDateKey(day)}>
|
||||
<span>{day.toLocaleDateString('fr-FR', { weekday: 'short' })}</span>
|
||||
<strong>{day.getDate()}</strong>
|
||||
</div>
|
||||
@ -278,8 +298,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
{hours.map((hour) => <span key={hour}>{formatHour(hour)}</span>)}
|
||||
</div>
|
||||
|
||||
{weekDays.map((day) => (
|
||||
<div className="schedule-day-column" key={formatDateKey(day)}>
|
||||
{weekDays.map((day, dayIndex) => (
|
||||
<div className={`schedule-day-column${dayIndex === selectedDayIndex ? ' is-selected' : ''}`} key={formatDateKey(day)}>
|
||||
{hours.map((hour) => <span className="schedule-hour-line" key={hour} />)}
|
||||
{isAdmin ? getAttendanceColumns(getAttendancesForDay(day)).map(({ start, end, username, color }, columnIndex) => {
|
||||
const startTime = formatSegmentTime(start);
|
||||
@ -316,7 +336,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
key={`${formatDateKey(day)}-${start}-${end}`}
|
||||
>
|
||||
<strong>{startTime} - {endTime}</strong>
|
||||
<span>{segmentLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user