Compare commits
45
Commits
stable
..
19b8be684f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b8be684f | ||
|
|
5950d85876 | ||
|
|
4339e1e53d | ||
|
|
defa438edf | ||
|
|
e990d576cf | ||
|
|
e8c6665a06 | ||
|
|
a4e6cfc50e | ||
|
|
80d69a5463 | ||
|
|
0579fda553 | ||
|
|
d14550a1cf | ||
|
|
2645cf1cd6 | ||
|
|
e2ebc6a0a1 | ||
|
|
090ce6e13b | ||
|
|
d66bdd04be | ||
|
|
d8572e7fd6 | ||
|
|
222d7c702f | ||
|
|
537c46127f | ||
|
|
ed18dcab10 | ||
|
|
bb92f010bd | ||
|
|
42bb872c41 | ||
|
|
fac3ae9baa | ||
|
|
5c28981ac5 | ||
|
|
57ce5af0f4 | ||
|
|
c1204a3050 | ||
|
|
9d4363b2a7 | ||
|
|
af06ab1e66 | ||
|
|
aa148354ec | ||
|
|
a10dc5a195 | ||
|
|
04c0b05aae | ||
|
|
d0b730c8ab | ||
|
|
bc8362bdb7 | ||
|
|
ac3178903d | ||
|
|
aec1990ec9 | ||
|
|
5da2ab9005 | ||
|
|
b2d6414fab | ||
|
|
fbafef8f2c | ||
|
|
135c7c2255 | ||
|
|
9cce326046 | ||
|
|
d697083f54 | ||
|
|
ae786426fd | ||
|
|
e4f7a35f0f | ||
|
|
8a6768b316 | ||
|
|
3892a8beab | ||
|
|
d39bc55be3 | ||
|
|
e0debf0394 |
@@ -1,18 +0,0 @@
|
|||||||
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# Fichiers binaires : pas de conversion
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.webp binary
|
|
||||||
*.pdf binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.ttf binary
|
|
||||||
*.eot binary
|
|
||||||
|
|
||||||
# Scripts shell : toujours LF
|
|
||||||
*.sh text eol=lf
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Crée l'issue Gitea "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API"
|
||||||
|
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
|
||||||
|
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon docs/BRIEFING-FRONTEND.md (voir PROCEDURE-API-GITEA.md)
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) {
|
||||||
|
token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
|
||||||
|
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/PROCEDURE-API-GITEA.md)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Description
|
||||||
|
|
||||||
|
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login.
|
||||||
|
|
||||||
|
**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc
|
||||||
|
|
||||||
|
## Tâches
|
||||||
|
|
||||||
|
- [ ] Créer un service ou méthode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent
|
||||||
|
- [ ] Construire le body (DTO) à partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohérence avec le backend (#18)
|
||||||
|
- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur
|
||||||
|
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
|
||||||
|
|
||||||
|
## Référence
|
||||||
|
|
||||||
|
20_WORKFLOW-CREATION-COMPTE.md § Étape 3 – Inscription d'un parent, backend #18`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'token ' + token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(d);
|
||||||
|
if (o.number) {
|
||||||
|
console.log('NUMBER:', o.number);
|
||||||
|
console.log('URL:', o.html_url);
|
||||||
|
} else {
|
||||||
|
console.error('Erreur API:', o.message || d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Réponse:', d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
|
||||||
|
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
|
||||||
|
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } };
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const state of ['open', 'closed']) {
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues');
|
||||||
|
if (raw && raw.message && !Array.isArray(raw)) {
|
||||||
|
console.error('API:', raw.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const list = Array.isArray(raw) ? raw : [];
|
||||||
|
for (const i of list) {
|
||||||
|
if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state });
|
||||||
|
}
|
||||||
|
if (list.length < 50) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const all = [...seen.values()].sort((a, b) => a.number - b.number);
|
||||||
|
console.log(JSON.stringify(all, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
@@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
|||||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,6 +54,7 @@ import { DocumentsLegauxModule } from './modules/documents-legaux';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
|
RelaisModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
export default new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
host: process.env.DATABASE_HOST,
|
||||||
|
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||||
|
username: process.env.DATABASE_USERNAME,
|
||||||
|
password: process.env.DATABASE_PASSWORD,
|
||||||
|
database: process.env.DATABASE_NAME,
|
||||||
|
entities: ['src/**/*.entity.ts'],
|
||||||
|
migrations: ['src/migrations/*.ts'],
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
@Entity('relais', { schema: 'public' })
|
||||||
|
export class Relais {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'nom' })
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@Column({ name: 'adresse' })
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@Column({ name: 'ligne_fixe', nullable: true })
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@Column({ default: true, name: 'actif' })
|
||||||
|
actif: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'notes', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => Users, user => user.relais)
|
||||||
|
gestionnaires: Users[];
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryGeneratedColumn, Column,
|
Entity, PrimaryGeneratedColumn, Column,
|
||||||
CreateDateColumn, UpdateDateColumn,
|
CreateDateColumn, UpdateDateColumn,
|
||||||
OneToOne, OneToMany
|
OneToOne, OneToMany, ManyToOne, JoinColumn
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||||
import { Parents } from './parents.entity';
|
import { Parents } from './parents.entity';
|
||||||
import { Message } from './messages.entity';
|
import { Message } from './messages.entity';
|
||||||
|
import { Relais } from './relais.entity';
|
||||||
|
|
||||||
// Enums alignés avec la BDD PostgreSQL
|
// Enums alignés avec la BDD PostgreSQL
|
||||||
export enum RoleType {
|
export enum RoleType {
|
||||||
@@ -80,7 +81,7 @@ export class Users {
|
|||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: StatutUtilisateurType,
|
enum: StatutUtilisateurType,
|
||||||
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
||||||
default: StatutUtilisateurType.EN_ATTENTE,
|
default: StatutUtilisateurType.ACTIF,
|
||||||
name: 'statut'
|
name: 'statut'
|
||||||
})
|
})
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
@@ -147,4 +148,11 @@ export class Users {
|
|||||||
|
|
||||||
@OneToMany(() => Parents, parent => parent.co_parent)
|
@OneToMany(() => Parents, parent => parent.co_parent)
|
||||||
co_parent_in?: Parents[];
|
co_parent_in?: Parents[];
|
||||||
|
|
||||||
|
@Column({ nullable: true, name: 'relais_id' })
|
||||||
|
relaisId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'relais_id' })
|
||||||
|
relais?: Relais;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { AppConfigModule } from '../config/config.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
private readonly logger = new Logger(MailService.name);
|
||||||
|
|
||||||
|
constructor(private readonly configService: AppConfigService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoi d'un email générique
|
||||||
|
* @param to Destinataire
|
||||||
|
* @param subject Sujet
|
||||||
|
* @param html Contenu HTML
|
||||||
|
* @param text Contenu texte (optionnel)
|
||||||
|
*/
|
||||||
|
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Récupération de la configuration SMTP
|
||||||
|
const smtpHost = this.configService.get<string>('smtp_host');
|
||||||
|
const smtpPort = this.configService.get<number>('smtp_port');
|
||||||
|
const smtpSecure = this.configService.get<boolean>('smtp_secure');
|
||||||
|
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
|
||||||
|
const smtpUser = this.configService.get<string>('smtp_user');
|
||||||
|
const smtpPassword = this.configService.get<string>('smtp_password');
|
||||||
|
const emailFromName = this.configService.get<string>('email_from_name');
|
||||||
|
const emailFromAddress = this.configService.get<string>('email_from_address');
|
||||||
|
|
||||||
|
// Import dynamique de nodemailer
|
||||||
|
const nodemailer = await import('nodemailer');
|
||||||
|
|
||||||
|
// Configuration du transporteur
|
||||||
|
const transportConfig: any = {
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpSecure,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (smtpAuthRequired && smtpUser && smtpPassword) {
|
||||||
|
transportConfig.auth = {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(transportConfig);
|
||||||
|
|
||||||
|
// Envoi de l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${emailFromName}" <${emailFromAddress}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoi de l'email de bienvenue pour un gestionnaire
|
||||||
|
* @param to Email du gestionnaire
|
||||||
|
* @param prenom Prénom
|
||||||
|
* @param nom Nom
|
||||||
|
* @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable)
|
||||||
|
* @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation
|
||||||
|
* Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien.
|
||||||
|
* Le ticket #24 parle de "API Création mot de passe" via token.
|
||||||
|
* Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé).
|
||||||
|
* Si on suit le ticket #35 (Frontend), on saisit un mot de passe.
|
||||||
|
* Donc on envoie juste un email de confirmation de création de compte.
|
||||||
|
*/
|
||||||
|
async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise<void> {
|
||||||
|
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
|
||||||
|
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||||
|
|
||||||
|
const subject = `Bienvenue sur ${appName}`;
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
|
||||||
|
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
|
||||||
|
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
|
||||||
|
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
|
||||||
|
</div>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRelaisDto {
|
||||||
|
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0123456789', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ default: true, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
actif?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Notes internes...', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateRelaisDto } from './create-relais.dto';
|
||||||
|
|
||||||
|
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
@ApiTags('Relais')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Controller('relais')
|
||||||
|
export class RelaisController {
|
||||||
|
constructor(private readonly relaisService: RelaisService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Créer un relais' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
|
||||||
|
create(@Body() createRelaisDto: CreateRelaisDto) {
|
||||||
|
return this.relaisService.create(createRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
|
findAll() {
|
||||||
|
return this.relaisService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.relaisService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Mettre à jour un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
|
||||||
|
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
return this.relaisService.update(id, updateRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Supprimer un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.relaisService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { RelaisController } from './relais.controller';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Relais]),
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
controllers: [RelaisController],
|
||||||
|
providers: [RelaisService],
|
||||||
|
exports: [RelaisService],
|
||||||
|
})
|
||||||
|
export class RelaisModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RelaisService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Relais)
|
||||||
|
private readonly relaisRepository: Repository<Relais>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(createRelaisDto: CreateRelaisDto) {
|
||||||
|
const relais = this.relaisRepository.create(createRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.relaisRepository.find({ order: { nom: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const relais = await this.relaisRepository.findOne({ where: { id } });
|
||||||
|
if (!relais) {
|
||||||
|
throw new NotFoundException(`Relais #${id} not found`);
|
||||||
|
}
|
||||||
|
return relais;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
Object.assign(relais, updateRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
return this.relaisRepository.remove(relais);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
|
||||||
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateAdminDto extends PickType(CreateUserDto, [
|
||||||
|
'nom',
|
||||||
|
'prenom',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'telephone'
|
||||||
|
] as const) {}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
import { IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
|
||||||
|
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
relaisId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export class CreateUserDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
nom: string;
|
nom: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
|
@ApiProperty({ enum: GenreType, required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GenreType)
|
@IsEnum(GenreType)
|
||||||
genre?: GenreType = GenreType.AUTRE;
|
genre?: GenreType;
|
||||||
|
|
||||||
@ApiProperty({ enum: RoleType })
|
@ApiProperty({ enum: RoleType })
|
||||||
@IsEnum(RoleType)
|
@IsEnum(RoleType)
|
||||||
@@ -86,7 +86,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consentement_photo?: boolean = false;
|
consentement_photo?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -96,7 +96,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
changement_mdp_obligatoire?: boolean = false;
|
changement_mdp_obligatoire?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: true })
|
@ApiProperty({ example: true })
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PartialType } from "@nestjs/swagger";
|
import { PartialType } from "@nestjs/swagger";
|
||||||
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
|
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
|
||||||
|
|
||||||
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
|
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { GestionnairesController } from './gestionnaires.controller';
|
|||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthModule } from 'src/routes/auth/auth.module';
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Users]),
|
TypeOrmModule.forFeature([Users]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [GestionnairesController],
|
controllers: [GestionnairesController],
|
||||||
providers: [GestionnairesService],
|
providers: [GestionnairesService],
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
||||||
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { MailService } from 'src/modules/mail/mail.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GestionnairesService {
|
export class GestionnairesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly gestionnaireRepository: Repository<Users>,
|
private readonly gestionnaireRepository: Repository<Users>,
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d’un gestionnaire
|
// Création d’un gestionnaire
|
||||||
@@ -30,30 +32,51 @@ export class GestionnairesService {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
genre: dto.genre,
|
// genre: dto.genre, // Retiré
|
||||||
statut: dto.statut,
|
// statut: dto.statut, // Retiré
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
// adresse: dto.adresse, // Retiré
|
||||||
photo_url: dto.photo_url,
|
// photo_url: dto.photo_url, // Retiré
|
||||||
consentement_photo: dto.consentement_photo ?? false,
|
// consentement_photo: dto.consentement_photo ?? false, // Retiré
|
||||||
date_consentement_photo: dto.date_consentement_photo
|
// date_consentement_photo: dto.date_consentement_photo // Retiré
|
||||||
? new Date(dto.date_consentement_photo)
|
// ? new Date(dto.date_consentement_photo)
|
||||||
: undefined,
|
// : undefined,
|
||||||
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
|
changement_mdp_obligatoire: true,
|
||||||
role: RoleType.GESTIONNAIRE,
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
relaisId: dto.relaisId,
|
||||||
});
|
});
|
||||||
return this.gestionnaireRepository.save(entity);
|
|
||||||
|
const savedUser = await this.gestionnaireRepository.save(entity);
|
||||||
|
|
||||||
|
// Envoi de l'email de bienvenue
|
||||||
|
try {
|
||||||
|
await this.mailService.sendGestionnaireWelcomeEmail(
|
||||||
|
savedUser.email,
|
||||||
|
savedUser.prenom || '',
|
||||||
|
savedUser.nom || '',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
|
||||||
|
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des gestionnaires
|
// Liste des gestionnaires
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
|
return this.gestionnaireRepository.find({
|
||||||
|
where: { role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un gestionnaire par ID
|
// Récupérer un gestionnaire par ID
|
||||||
async findOne(id: string): Promise<Users> {
|
async findOne(id: string): Promise<Users> {
|
||||||
const gestionnaire = await this.gestionnaireRepository.findOne({
|
const gestionnaire = await this.gestionnaireRepository.findOne({
|
||||||
where: { id, role: RoleType.GESTIONNAIRE },
|
where: { id, role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
});
|
});
|
||||||
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
||||||
return gestionnaire;
|
return gestionnaire;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { User } from 'src/common/decorators/user.decorator';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { CreateUserDto } from './dto/create_user.dto';
|
import { CreateUserDto } from './dto/create_user.dto';
|
||||||
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@@ -15,6 +16,17 @@ import { UpdateUserDto } from './dto/update_user.dto';
|
|||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(private readonly userService: UserService) { }
|
||||||
|
|
||||||
|
// Création d'un administrateur (réservée aux super admins)
|
||||||
|
@Post('admin')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||||
|
createAdmin(
|
||||||
|
@Body() dto: CreateAdminDto,
|
||||||
|
@User() currentUser: Users
|
||||||
|
) {
|
||||||
|
return this.userService.createAdmin(dto, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
// Création d'un utilisateur (réservée aux super admins)
|
// Création d'un utilisateur (réservée aux super admins)
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
@@ -43,9 +55,9 @@ export class UserController {
|
|||||||
return this.userService.findOne(id);
|
return this.userService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier un utilisateur (réservé super_admin)
|
// Modifier un utilisateur (réservé super_admin et admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
updateUser(
|
updateUser(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm";
|
|||||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||||
import { In, Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import { CreateUserDto } from "./dto/create_user.dto";
|
import { CreateUserDto } from "./dto/create_user.dto";
|
||||||
|
import { CreateAdminDto } from "./dto/create_admin.dto";
|
||||||
import { UpdateUserDto } from "./dto/update_user.dto";
|
import { UpdateUserDto } from "./dto/update_user.dto";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
@@ -106,6 +107,31 @@ export class UserService {
|
|||||||
return this.findOne(saved.id);
|
return this.findOne(saved.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
|
if (exist) throw new BadRequestException('Email déjà utilisé');
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt();
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.password, salt);
|
||||||
|
|
||||||
|
const entity = this.usersRepository.create({
|
||||||
|
email: dto.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
changement_mdp_obligatoire: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.usersRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
@@ -129,11 +155,26 @@ export class UserService {
|
|||||||
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
||||||
const user = await this.findOne(id);
|
const user = await this.findOne(id);
|
||||||
|
|
||||||
|
// Le super administrateur conserve une identité figée.
|
||||||
|
if (
|
||||||
|
user.role === RoleType.SUPER_ADMIN &&
|
||||||
|
(dto.nom !== undefined || dto.prenom !== undefined)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Interdire changement de rôle si pas super admin
|
// Interdire changement de rôle si pas super admin
|
||||||
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un admin ne peut pas modifier un super admin
|
||||||
|
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
||||||
if (
|
if (
|
||||||
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
||||||
@@ -225,6 +266,12 @@ export class UserService {
|
|||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
const user = await this.findOne(id);
|
||||||
|
if (user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le super administrateur ne peut pas être supprimé',
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await this.usersRepository.delete(id);
|
const result = await this.usersRepository.delete(id);
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
throw new NotFoundException('Utilisateur introuvable');
|
throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
|||||||
+18
-2
@@ -331,13 +331,29 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
|
|||||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Modification Table : utilisateurs (ajout colonnes documents)
|
-- Table : relais
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE relais (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
nom VARCHAR(255) NOT NULL,
|
||||||
|
adresse TEXT NOT NULL,
|
||||||
|
horaires_ouverture JSONB,
|
||||||
|
ligne_fixe VARCHAR(20),
|
||||||
|
actif BOOLEAN DEFAULT true,
|
||||||
|
notes TEXT,
|
||||||
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
ALTER TABLE utilisateurs
|
ALTER TABLE utilisateurs
|
||||||
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
||||||
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
||||||
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
|
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Seed : Documents légaux génériques v1
|
-- Seed : Documents légaux génériques v1
|
||||||
|
|||||||
+163
-22
@@ -1,9 +1,9 @@
|
|||||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||||
|
|
||||||
**Version** : 1.4
|
**Version** : 1.5
|
||||||
**Date** : 9 Février 2026
|
**Date** : 24 Février 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
**Estimation totale** : ~184h
|
**Estimation totale** : ~208h
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,8 +27,16 @@
|
|||||||
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
||||||
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
||||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||||
| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
|
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
||||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
|
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
|
||||||
|
| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert |
|
||||||
|
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
|
||||||
|
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé |
|
||||||
|
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
|
||||||
|
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé |
|
||||||
|
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
|
||||||
|
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
|
||||||
|
| 89 | Log des appels API en mode debug | Ouvert |
|
||||||
|
|
||||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||||
|
|
||||||
@@ -316,21 +324,22 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
|
|||||||
|
|
||||||
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
||||||
|
|
||||||
### Ticket #17 : [Backend] API Création gestionnaire
|
### Ticket #17 : [Backend] API Création gestionnaire ✅
|
||||||
**Estimation** : 3h
|
**Estimation** : 3h
|
||||||
**Labels** : `backend`, `p2`, `auth`
|
**Labels** : `backend`, `p2`, `auth`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Endpoint `POST /api/v1/gestionnaires`
|
- [x] Endpoint `POST /api/v1/gestionnaires`
|
||||||
- [ ] Validation DTO
|
- [x] Validation DTO
|
||||||
- [ ] Hash bcrypt
|
- [x] Hash bcrypt
|
||||||
- [ ] Flag `changement_mdp_obligatoire = TRUE`
|
- [x] Flag `changement_mdp_obligatoire = TRUE`
|
||||||
- [ ] Guards (super_admin only)
|
- [x] Guards (super_admin only)
|
||||||
- [ ] Email de notification (utiliser MailService avec config dynamique)
|
- [x] Email de notification (utiliser MailService avec config dynamique)
|
||||||
- [ ] Tests unitaires
|
- [x] Tests unitaires
|
||||||
|
|
||||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
|
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
|
||||||
|
|
||||||
@@ -641,6 +650,39 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
|
||||||
|
**Estimation** : 4h
|
||||||
|
**Labels** : `backend`, `p2`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
|
||||||
|
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
|
||||||
|
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
|
||||||
|
- [x] Validations (champs requis, format horaires)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
|
||||||
|
**Estimation** : 3h
|
||||||
|
**Labels** : `backend`, `p2`, `auth`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
|
||||||
|
- [ ] Champs autorisés : nom, prenom, email, password, telephone
|
||||||
|
- [ ] Champs exclus : adresse, ville, photo, etc.
|
||||||
|
- [ ] Rôle forcé à `ADMINISTRATEUR`
|
||||||
|
- [ ] Validation stricte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
|
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
|
||||||
|
|
||||||
### Ticket #35 : [Frontend] Écran Création Gestionnaire
|
### Ticket #35 : [Frontend] Écran Création Gestionnaire
|
||||||
@@ -894,9 +936,10 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
|
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API ✅
|
||||||
**Estimation** : 8h
|
**Estimation** : 8h
|
||||||
**Labels** : `frontend`, `p3`, `admin`
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
||||||
@@ -1018,6 +1061,89 @@ Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et coh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #91 : [Frontend] Inscription AM – Branchement soumission formulaire à l'API
|
||||||
|
**Estimation** : 3h
|
||||||
|
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscription.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Construire le body (DTO) à partir de `AmRegistrationData`
|
||||||
|
- [ ] Appel HTTP `POST /api/v1/auth/register/am`
|
||||||
|
- [ ] Gestion réponse (201 : succès + redirection ; 4xx : erreur)
|
||||||
|
- [ ] Conversion photo en base64 si nécessaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #101 : [Frontend] Inscription Parent – Branchement soumission formulaire à l'API
|
||||||
|
**Estimation** : 4h
|
||||||
|
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale de confirmation puis redirige vers le login. Ce ticket vise à envoyer les données collectées (Parent 1, Parent 2 optionnel, enfants, présentation, CGU) à l'API.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Créer un service ou méthode (ex. `AuthService.registerParent` ou `UserService`) appelant `POST /api/v1/auth/register/parent`
|
||||||
|
- [ ] Construire le body (DTO) à partir de `UserRegistrationData` (parent1, parent2, children, motivationText, CGU acceptée, etc.) en cohérence avec le contrat backend (voir ticket #18 refonte)
|
||||||
|
- [ ] Dans `ParentRegisterStep5Screen`, au clic « Soumettre » : appel API puis en cas de succès afficher la modale et redirection vers `/login` ; en cas d'erreur afficher le message (SnackBar/dialog)
|
||||||
|
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
|
||||||
|
- [ ] Optionnel : réinitialiser ou conserver `UserRegistrationData` après succès (selon UX)
|
||||||
|
|
||||||
|
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-3--inscription-dun-parent), backend #18 (refonte API inscription parent).
|
||||||
|
|
||||||
|
**Création** : issue Gitea #101 créée. Pour recréer ou script : `node backend/scripts/create-gitea-issue-parent-api.js` (token dans `.gitea-token` ou voir [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ✅
|
||||||
|
**Estimation** : 4h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`, `ux`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
|
||||||
|
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
|
||||||
|
- [ ] Standardiser les états (Loading, Erreur, Vide)
|
||||||
|
- [ ] Factoriser les composants partagés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ✅
|
||||||
|
**Estimation** : 5h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
|
||||||
|
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
|
||||||
|
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
|
||||||
|
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
|
||||||
|
**Estimation** : 3h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
|
||||||
|
- [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
|
||||||
|
- [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
|
||||||
|
- [x] Gestion succès/erreur et rafraîchissement liste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔵 PRIORITÉ 4 : Tests & Documentation
|
## 🔵 PRIORITÉ 4 : Tests & Documentation
|
||||||
|
|
||||||
### Ticket #52 : [Tests] Tests unitaires Backend
|
### Ticket #52 : [Tests] Tests unitaires Backend
|
||||||
@@ -1133,6 +1259,20 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #89 : Log des appels API en mode debug
|
||||||
|
**Estimation** : 2h
|
||||||
|
**Labels** : `backend`, `monitoring`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Ajouter des logs détaillés pour les appels API en mode debug pour faciliter le diagnostic.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Middleware ou Intercepteur pour logger les requêtes entrantes (méthode, URL, body)
|
||||||
|
- [ ] Logger les réponses (status, temps d'exécution)
|
||||||
|
- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||||
**Estimation** : 4h
|
**Estimation** : 4h
|
||||||
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
||||||
@@ -1235,28 +1375,29 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
## 📊 Résumé final
|
## 📊 Résumé final
|
||||||
|
|
||||||
**Total** : 65 tickets
|
**Total** : 72 tickets
|
||||||
**Estimation** : ~184h de développement
|
**Estimation** : ~208h de développement
|
||||||
|
|
||||||
### Par priorité
|
### Par priorité
|
||||||
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
||||||
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
||||||
- **P2 (Backend)** : 18 tickets (~50h)
|
- **P2 (Backend)** : 19 tickets (~54h)
|
||||||
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
|
- **P3 (Frontend)** : 25 tickets (~83h)
|
||||||
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
||||||
- **Critiques** : 6 tickets (~13h)
|
- **Critiques** : 6 tickets (~13h)
|
||||||
- **Juridique** : 1 ticket (~8h)
|
- **Juridique** : 1 ticket (~8h)
|
||||||
|
|
||||||
### Par domaine
|
### Par domaine
|
||||||
- **BDD** : 7 tickets
|
- **BDD** : 7 tickets
|
||||||
- **Backend** : 23 tickets
|
- **Backend** : 24 tickets
|
||||||
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
|
- **Frontend** : 25 tickets
|
||||||
- **Tests** : 3 tickets
|
- **Tests** : 3 tickets
|
||||||
- **Documentation** : 5 tickets
|
- **Documentation** : 5 tickets
|
||||||
- **Infra** : 2 tickets
|
- **Infra** : 2 tickets
|
||||||
- **Juridique** : 1 ticket
|
- **Juridique** : 1 ticket
|
||||||
|
|
||||||
### Modifications par rapport à la version initiale
|
### Modifications par rapport à la version initiale
|
||||||
|
- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
|
||||||
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
|
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
|
||||||
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
||||||
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
||||||
@@ -1270,7 +1411,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 9 Février 2026
|
**Dernière mise à jour** : 24 Février 2026
|
||||||
**Version** : 1.4
|
**Version** : 1.6
|
||||||
**Statut** : ✅ Aligné avec le dépôt Gitea
|
**Statut** : ✅ Aligné avec le dépôt Gitea
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -255,4 +255,24 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
|||||||
#### X.1.3 Impact sur l'application
|
#### X.1.3 Impact sur l'application
|
||||||
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
|
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
|
||||||
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
|
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
|
||||||
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
||||||
|
|
||||||
|
## 8. Évolution future - Gouvernance intra-RPE
|
||||||
|
|
||||||
|
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
|
||||||
|
|
||||||
|
#### 8.1.1 Situation actuelle
|
||||||
|
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
|
||||||
|
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
|
||||||
|
|
||||||
|
#### 8.1.2 Évolution à prévoir
|
||||||
|
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
|
||||||
|
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
|
||||||
|
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
|
||||||
|
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
|
||||||
|
|
||||||
|
#### 8.1.3 Impact attendu
|
||||||
|
- Évolution du modèle de données vers un RBAC intra-RPE.
|
||||||
|
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
||||||
|
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||||
|
- Clarification des workflows décisionnels dans l'application.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SuperNounou – SSS-001
|
# SuperNounou – SSS-001
|
||||||
## Spécification technique & opérationnelle unifiée
|
## Spécification technique & opérationnelle unifiée
|
||||||
_Version 0.2 – 24 avril 2025_
|
_Version 0.3 – 27 janvier 2026_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler l’API ».
|
|||||||
### B.4 Intégrations futures
|
### B.4 Intégrations futures
|
||||||
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
|
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
|
||||||
|
|
||||||
|
### B.5 Contrat de gestion des comptes d'administration
|
||||||
|
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
|
||||||
|
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
|
||||||
|
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
|
||||||
|
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
|
||||||
|
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# C – Déploiement, CI/CD et Observabilité *(nouveau)*
|
# C – Déploiement, CI/CD et Observabilité *(nouveau)*
|
||||||
@@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|
|||||||
|---------|------------|------------------|---------------------------------|
|
|---------|------------|------------------|---------------------------------|
|
||||||
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
|
||||||
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
|
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
|
||||||
|
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import '../screens/auth/am_register_step3_screen.dart';
|
|||||||
import '../screens/auth/am_register_step4_screen.dart';
|
import '../screens/auth/am_register_step4_screen.dart';
|
||||||
import '../screens/home/home_screen.dart';
|
import '../screens/home/home_screen.dart';
|
||||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||||
|
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
||||||
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
||||||
|
import '../screens/am/am_dashboard_screen.dart';
|
||||||
|
import '../screens/legal/privacy_page.dart';
|
||||||
|
import '../screens/legal/legal_page.dart';
|
||||||
import '../screens/unknown_screen.dart';
|
import '../screens/unknown_screen.dart';
|
||||||
|
|
||||||
// --- Provider Instances ---
|
// --- Provider Instances ---
|
||||||
@@ -53,13 +57,26 @@ class AppRouter {
|
|||||||
path: '/admin-dashboard',
|
path: '/admin-dashboard',
|
||||||
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
|
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/gestionnaire-dashboard',
|
||||||
|
builder: (BuildContext context, GoRouterState state) => const GestionnaireDashboardScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/parent-dashboard',
|
path: '/parent-dashboard',
|
||||||
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
|
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/am-dashboard',
|
path: '/am-dashboard',
|
||||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
builder: (BuildContext context, GoRouterState state) =>
|
||||||
|
const AmDashboardScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/privacy',
|
||||||
|
builder: (BuildContext context, GoRouterState state) => const PrivacyPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/legal',
|
||||||
|
builder: (BuildContext context, GoRouterState state) => const LegalPage(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- Parent Registration Flow ---
|
// --- Parent Registration Flow ---
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
class RelaisModel {
|
||||||
|
final String id;
|
||||||
|
final String nom;
|
||||||
|
final String adresse;
|
||||||
|
final Map<String, dynamic>? horairesOuverture;
|
||||||
|
final String? ligneFixe;
|
||||||
|
final bool actif;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
const RelaisModel({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.adresse,
|
||||||
|
this.horairesOuverture,
|
||||||
|
this.ligneFixe,
|
||||||
|
required this.actif,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RelaisModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RelaisModel(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
nom: (json['nom'] ?? '').toString(),
|
||||||
|
adresse: (json['adresse'] ?? '').toString(),
|
||||||
|
horairesOuverture: json['horaires_ouverture'] is Map<String, dynamic>
|
||||||
|
? json['horaires_ouverture'] as Map<String, dynamic>
|
||||||
|
: null,
|
||||||
|
ligneFixe: json['ligne_fixe'] as String?,
|
||||||
|
actif: json['actif'] as bool? ?? true,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class AppUser {
|
|||||||
final String? adresse;
|
final String? adresse;
|
||||||
final String? ville;
|
final String? ville;
|
||||||
final String? codePostal;
|
final String? codePostal;
|
||||||
|
final String? relaisId;
|
||||||
|
final String? relaisNom;
|
||||||
|
|
||||||
AppUser({
|
AppUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -29,13 +31,19 @@ class AppUser {
|
|||||||
this.adresse,
|
this.adresse,
|
||||||
this.ville,
|
this.ville,
|
||||||
this.codePostal,
|
this.codePostal,
|
||||||
|
this.relaisId,
|
||||||
|
this.relaisNom,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||||
|
final relaisJson = json['relais'];
|
||||||
|
final relaisMap =
|
||||||
|
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
|
||||||
|
|
||||||
return AppUser(
|
return AppUser(
|
||||||
id: json['id'] as String,
|
id: (json['id'] as String?) ?? '',
|
||||||
email: json['email'] as String,
|
email: (json['email'] as String?) ?? '',
|
||||||
role: json['role'] as String,
|
role: (json['role'] as String?) ?? '',
|
||||||
createdAt: json['cree_le'] != null
|
createdAt: json['cree_le'] != null
|
||||||
? DateTime.parse(json['cree_le'] as String)
|
? DateTime.parse(json['cree_le'] as String)
|
||||||
: (json['createdAt'] != null
|
: (json['createdAt'] != null
|
||||||
@@ -56,6 +64,9 @@ class AppUser {
|
|||||||
adresse: json['adresse'] as String?,
|
adresse: json['adresse'] as String?,
|
||||||
ville: json['ville'] as String?,
|
ville: json['ville'] as String?,
|
||||||
codePostal: json['code_postal'] as String?,
|
codePostal: json['code_postal'] as String?,
|
||||||
|
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
||||||
|
?.toString(),
|
||||||
|
relaisNom: relaisMap['nom']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +86,8 @@ class AppUser {
|
|||||||
'adresse': adresse,
|
'adresse': adresse,
|
||||||
'ville': ville,
|
'ville': ville,
|
||||||
'code_postal': codePostal,
|
'code_postal': codePostal,
|
||||||
|
'relais_id': relaisId,
|
||||||
|
'relais_nom': relaisNom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||||
|
|
||||||
class AdminDashboardScreen extends StatefulWidget {
|
class AdminDashboardScreen extends StatefulWidget {
|
||||||
const AdminDashboardScreen({super.key});
|
const AdminDashboardScreen({super.key});
|
||||||
@@ -17,8 +17,9 @@ class AdminDashboardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||||
bool? _setupCompleted;
|
bool? _setupCompleted;
|
||||||
|
AppUser? _user;
|
||||||
int mainTabIndex = 0;
|
int mainTabIndex = 0;
|
||||||
int subIndex = 0;
|
int settingsSubIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -26,19 +27,28 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
_loadSetupStatus();
|
_loadSetupStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSetupStatus() async {
|
Future<void> _loadSetupStatus() async {
|
||||||
try {
|
try {
|
||||||
final completed = await ConfigurationService.getSetupStatus();
|
final completed = await ConfigurationService.getSetupStatus();
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_setupCompleted = completed;
|
_setupCompleted = completed;
|
||||||
|
_user = user;
|
||||||
if (!completed) mainTabIndex = 1;
|
if (!completed) mainTabIndex = 1;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() {
|
if (mounted) {
|
||||||
_setupCompleted = false;
|
setState(() {
|
||||||
mainTabIndex = 1;
|
_setupCompleted = false;
|
||||||
});
|
mainTabIndex = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +58,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSubTabChange(int index) {
|
void onSettingsSubTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
subIndex = index;
|
settingsSubIndex = index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,25 +74,39 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60.0),
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
child: Container(
|
child: DashboardBandeau(
|
||||||
decoration: BoxDecoration(
|
tabItems: [
|
||||||
border: Border(
|
DashboardTabItem(
|
||||||
bottom: BorderSide(color: Colors.grey.shade300),
|
label: 'Gestion des utilisateurs',
|
||||||
|
enabled: _setupCompleted!,
|
||||||
),
|
),
|
||||||
),
|
const DashboardTabItem(label: 'Paramètres'),
|
||||||
child: DashboardAppBarAdmin(
|
],
|
||||||
selectedIndex: mainTabIndex,
|
selectedTabIndex: mainTabIndex,
|
||||||
onTabChange: onMainTabChange,
|
onTabSelected: onMainTabChange,
|
||||||
setupCompleted: _setupCompleted!,
|
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||||
),
|
? _user!.fullName
|
||||||
|
: 'Admin',
|
||||||
|
userEmail: _user?.email,
|
||||||
|
userRole: _user?.role,
|
||||||
|
onProfileTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Modification du profil – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettingsTap: () => onMainTabChange(1),
|
||||||
|
onLogout: () {},
|
||||||
|
showLogoutConfirmation: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (mainTabIndex == 0)
|
if (mainTabIndex == 0)
|
||||||
DashboardUserManagementSubBar(
|
const SizedBox.shrink()
|
||||||
selectedSubIndex: subIndex,
|
else
|
||||||
onSubTabChange: onSubTabChange,
|
DashboardSettingsSubBar(
|
||||||
|
selectedSubIndex: settingsSubIndex,
|
||||||
|
onSubTabChange: onSettingsSubTabChange,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _getBody(),
|
child: _getBody(),
|
||||||
@@ -95,19 +119,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
if (mainTabIndex == 1) {
|
if (mainTabIndex == 1) {
|
||||||
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
|
return ParametresPanel(
|
||||||
}
|
redirectToLoginAfterSave: !_setupCompleted!,
|
||||||
switch (subIndex) {
|
selectedSettingsTabIndex: settingsSubIndex,
|
||||||
case 0:
|
);
|
||||||
return const GestionnaireManagementWidget();
|
|
||||||
case 1:
|
|
||||||
return const ParentManagementWidget();
|
|
||||||
case 2:
|
|
||||||
return const AssistanteMaternelleManagementWidget();
|
|
||||||
case 3:
|
|
||||||
return const AdminManagementWidget();
|
|
||||||
default:
|
|
||||||
return const Center(child: Text('Page non trouvée'));
|
|
||||||
}
|
}
|
||||||
|
return const UserManagementPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
|
class AdminCreateDialog extends StatefulWidget {
|
||||||
|
final AppUser? initialUser;
|
||||||
|
|
||||||
|
const AdminCreateDialog({
|
||||||
|
super.key,
|
||||||
|
this.initialUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminCreateDialog> createState() => _AdminCreateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final user = widget.initialUser;
|
||||||
|
if (user != null) {
|
||||||
|
_nomController.text = user.nom ?? '';
|
||||||
|
_prenomController.text = user.prenom ?? '';
|
||||||
|
_emailController.text = user.email;
|
||||||
|
_telephoneController.text = user.telephone ?? '';
|
||||||
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
|
_passwordController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _required(String? value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return '$field est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateEmail(String? value) {
|
||||||
|
final base = _required(value, 'Email');
|
||||||
|
if (base != null) return base;
|
||||||
|
final email = value!.trim();
|
||||||
|
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||||
|
if (!ok) return 'Format email invalide';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePassword(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Mot de passe');
|
||||||
|
if (base != null) return base;
|
||||||
|
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isEditMode) {
|
||||||
|
await UserService.updateAdmin(
|
||||||
|
adminId: widget.initialUser!.id,
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
password: _passwordController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _passwordController.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createAdmin(
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Administrateur modifié avec succès.'
|
||||||
|
: 'Administrateur créé avec succès.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString().replaceFirst('Exception: ', ''),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la suppression'),
|
||||||
|
content: Text(
|
||||||
|
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(widget.initialUser!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Administrateur supprimé.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Modifier un administrateur'
|
||||||
|
: 'Créer un administrateur',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isEditMode)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 620,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildNomField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildPrenomField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildEmailField(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPasswordField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildTelephoneField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_isEditMode) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.edit),
|
||||||
|
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _nomController,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Nom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPrenomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _prenomController,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Prénom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: _validateEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: _isEditMode
|
||||||
|
? const <String>[]
|
||||||
|
: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isEditMode
|
||||||
|
? 'Nouveau mot de passe'
|
||||||
|
: 'Mot de passe',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: _validatePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTelephoneField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Téléphone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,688 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
class GestionnairesCreate extends StatelessWidget {
|
class AdminUserFormDialog extends StatefulWidget {
|
||||||
const GestionnairesCreate({super.key});
|
final AppUser? initialUser;
|
||||||
|
final bool withRelais;
|
||||||
|
final bool adminMode;
|
||||||
|
final bool readOnly;
|
||||||
|
|
||||||
|
const AdminUserFormDialog({
|
||||||
|
super.key,
|
||||||
|
this.initialUser,
|
||||||
|
this.withRelais = true,
|
||||||
|
this.adminMode = false,
|
||||||
|
this.readOnly = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
final _passwordToggleFocusNode =
|
||||||
|
FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _isLoadingRelais = true;
|
||||||
|
List<RelaisModel> _relais = [];
|
||||||
|
String? _selectedRelaisId;
|
||||||
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
|
bool get _isSuperAdminTarget =>
|
||||||
|
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
||||||
|
bool get _isLockedAdminIdentity =>
|
||||||
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
|
String get _targetRoleKey {
|
||||||
|
if (widget.initialUser != null) {
|
||||||
|
return widget.initialUser!.role.toLowerCase();
|
||||||
|
}
|
||||||
|
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _targetRoleLabel {
|
||||||
|
switch (_targetRoleKey) {
|
||||||
|
case 'super_admin':
|
||||||
|
return 'Super administrateur';
|
||||||
|
case 'administrateur':
|
||||||
|
return 'Administrateur';
|
||||||
|
case 'gestionnaire':
|
||||||
|
return 'Gestionnaire';
|
||||||
|
case 'assistante_maternelle':
|
||||||
|
return 'Assistante maternelle';
|
||||||
|
case 'parent':
|
||||||
|
return 'Parent';
|
||||||
|
default:
|
||||||
|
return 'Utilisateur';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get _targetRoleIcon {
|
||||||
|
switch (_targetRoleKey) {
|
||||||
|
case 'super_admin':
|
||||||
|
return Icons.verified_user_outlined;
|
||||||
|
case 'administrateur':
|
||||||
|
return Icons.admin_panel_settings_outlined;
|
||||||
|
case 'gestionnaire':
|
||||||
|
return Icons.assignment_ind_outlined;
|
||||||
|
case 'assistante_maternelle':
|
||||||
|
return Icons.child_care_outlined;
|
||||||
|
case 'parent':
|
||||||
|
return Icons.supervisor_account_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.person_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final user = widget.initialUser;
|
||||||
|
if (user != null) {
|
||||||
|
_nomController.text = user.nom ?? '';
|
||||||
|
_prenomController.text = user.prenom ?? '';
|
||||||
|
_emailController.text = user.email;
|
||||||
|
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
||||||
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
|
_passwordController.clear();
|
||||||
|
final initialRelaisId = user.relaisId?.trim();
|
||||||
|
_selectedRelaisId =
|
||||||
|
(initialRelaisId == null || initialRelaisId.isEmpty)
|
||||||
|
? null
|
||||||
|
: initialRelaisId;
|
||||||
|
}
|
||||||
|
if (widget.withRelais) {
|
||||||
|
_loadRelais();
|
||||||
|
} else {
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
_passwordToggleFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRelais() async {
|
||||||
|
try {
|
||||||
|
final list = await RelaisService.getRelais();
|
||||||
|
if (!mounted) return;
|
||||||
|
final uniqueById = <String, RelaisModel>{};
|
||||||
|
for (final relais in list) {
|
||||||
|
uniqueById[relais.id] = relais;
|
||||||
|
}
|
||||||
|
|
||||||
|
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
||||||
|
if (_selectedRelaisId != null &&
|
||||||
|
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
||||||
|
final selected = uniqueById[_selectedRelaisId!];
|
||||||
|
if (selected != null) {
|
||||||
|
filtered.add(selected);
|
||||||
|
} else {
|
||||||
|
_selectedRelaisId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_relais = filtered;
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedRelaisId = null;
|
||||||
|
_relais = [];
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _required(String? value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return '$field est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateEmail(String? value) {
|
||||||
|
final base = _required(value, 'Email');
|
||||||
|
if (base != null) return base;
|
||||||
|
final email = value!.trim();
|
||||||
|
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||||
|
if (!ok) return 'Format email invalide';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePassword(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Mot de passe');
|
||||||
|
if (base != null) return base;
|
||||||
|
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePhone(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Téléphone');
|
||||||
|
if (base != null) return base;
|
||||||
|
final digits = _normalizePhone(value!);
|
||||||
|
if (digits.length != 10) {
|
||||||
|
return 'Le téléphone doit contenir 10 chiffres';
|
||||||
|
}
|
||||||
|
if (!digits.startsWith('0')) {
|
||||||
|
return 'Le téléphone doit commencer par 0';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizePhone(String raw) {
|
||||||
|
return raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPhoneForDisplay(String raw) {
|
||||||
|
final normalized = _normalizePhone(raw);
|
||||||
|
final digits =
|
||||||
|
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < digits.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _toTitleCase(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty) return trimmed;
|
||||||
|
final words = trimmed.split(RegExp(r'\s+'));
|
||||||
|
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
||||||
|
return normalizedWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalizeComposedWord(String word) {
|
||||||
|
if (word.isEmpty) return word;
|
||||||
|
final lower = word.toLowerCase();
|
||||||
|
final separators = <String>{"-", "'", "’"};
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var capitalizeNext = true;
|
||||||
|
|
||||||
|
for (var i = 0; i < lower.length; i++) {
|
||||||
|
final char = lower[i];
|
||||||
|
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||||||
|
buffer.write(char.toUpperCase());
|
||||||
|
capitalizeNext = false;
|
||||||
|
} else {
|
||||||
|
buffer.write(char);
|
||||||
|
capitalizeNext = separators.contains(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final normalizedNom = _toTitleCase(_nomController.text);
|
||||||
|
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||||
|
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||||||
|
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
|
if (_isEditMode) {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
||||||
|
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
||||||
|
await UserService.updateAdministrateur(
|
||||||
|
adminId: widget.initialUser!.id,
|
||||||
|
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
||||||
|
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty
|
||||||
|
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||||||
|
: normalizedPhone,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final currentUser = widget.initialUser!;
|
||||||
|
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||||
|
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||||
|
final initialEmail = currentUser.email.trim();
|
||||||
|
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||||||
|
|
||||||
|
final onlyRelaisChanged =
|
||||||
|
normalizedNom == initialNom &&
|
||||||
|
normalizedPrenom == initialPrenom &&
|
||||||
|
_emailController.text.trim() == initialEmail &&
|
||||||
|
normalizedPhone == initialPhone &&
|
||||||
|
!passwordProvided;
|
||||||
|
|
||||||
|
if (onlyRelaisChanged) {
|
||||||
|
await UserService.updateGestionnaireRelais(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.updateGestionnaire(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
await UserService.createAdministrateur(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _normalizePhone(_telephoneController.text),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createGestionnaire(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _normalizePhone(_telephoneController.text),
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? (widget.adminMode
|
||||||
|
? 'Administrateur modifié avec succès.'
|
||||||
|
: 'Gestionnaire modifié avec succès.')
|
||||||
|
: (widget.adminMode
|
||||||
|
? 'Administrateur créé avec succès.'
|
||||||
|
: 'Gestionnaire créé avec succès.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString().replaceFirst('Exception: ', ''),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSuperAdminTarget) return;
|
||||||
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la suppression'),
|
||||||
|
content: Text(
|
||||||
|
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(widget.initialUser!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return AlertDialog(
|
||||||
appBar: AppBar(
|
title: Row(
|
||||||
title: const Text('Créer un gestionnaire'),
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: const Color(0xFFEDE5FA),
|
||||||
|
child: Icon(
|
||||||
|
_targetRoleIcon,
|
||||||
|
size: 20,
|
||||||
|
color: const Color(0xFF6B3FA0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_isEditMode
|
||||||
|
? (widget.readOnly
|
||||||
|
? 'Consulter un "$_targetRoleLabel"'
|
||||||
|
: 'Modifier un "$_targetRoleLabel"')
|
||||||
|
: 'Créer un "$_targetRoleLabel"',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isEditMode && !widget.readOnly)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: const Center(
|
content: SizedBox(
|
||||||
child: Text('Formulaire de création de gestionnaire'),
|
width: 620,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPrenomField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildNomField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildEmailField(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPasswordField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildTelephoneField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.withRelais) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildRelaisField(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.readOnly) ...[
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
] else if (_isEditMode) ...[
|
||||||
|
if (!_isSuperAdminTarget)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.edit),
|
||||||
|
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _nomController,
|
||||||
|
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||||||
|
? null
|
||||||
|
: (v) => _required(v, 'Nom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPrenomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _prenomController,
|
||||||
|
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||||||
|
? null
|
||||||
|
: (v) => _required(v, 'Prénom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validateEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: _isEditMode
|
||||||
|
? const <String>[]
|
||||||
|
: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isEditMode
|
||||||
|
? 'Nouveau mot de passe'
|
||||||
|
: 'Mot de passe',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: widget.readOnly
|
||||||
|
? null
|
||||||
|
: ExcludeFocus(
|
||||||
|
child: IconButton(
|
||||||
|
focusNode: _passwordToggleFocusNode,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validatePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTelephoneField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: widget.readOnly
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
_FrenchPhoneNumberFormatter(),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validatePhone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRelaisField() {
|
||||||
|
final selectedValue = _selectedRelaisId != null &&
|
||||||
|
_relais.any((relais) => relais.id == _selectedRelaisId)
|
||||||
|
? _selectedRelaisId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
value: selectedValue,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Relais principal',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Aucun relais'),
|
||||||
|
),
|
||||||
|
..._relais.map(
|
||||||
|
(relais) => DropdownMenuItem<String?>(
|
||||||
|
value: relais.id,
|
||||||
|
child: Text(relais.nom),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (_isLoadingRelais || widget.readOnly)
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedRelaisId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_isLoadingRelais) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||||
|
const _FrenchPhoneNumberFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < normalized.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(normalized[i]);
|
||||||
|
}
|
||||||
|
final formatted = buffer.toString();
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||||
|
|
||||||
|
/// Dashboard assistante maternelle – page blanche avec bandeau générique.
|
||||||
|
/// Contenu détaillé à venir.
|
||||||
|
class AmDashboardScreen extends StatefulWidget {
|
||||||
|
const AmDashboardScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AmDashboardScreen> createState() => _AmDashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||||
|
int selectedTabIndex = 0;
|
||||||
|
AppUser? _user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUser() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (mounted) setState(() => _user = user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
|
child: DashboardBandeau(
|
||||||
|
tabItems: const [
|
||||||
|
DashboardTabItem(label: 'Mon tableau de bord'),
|
||||||
|
DashboardTabItem(label: 'Paramètres'),
|
||||||
|
],
|
||||||
|
selectedTabIndex: selectedTabIndex,
|
||||||
|
onTabSelected: (index) => setState(() => selectedTabIndex = index),
|
||||||
|
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||||
|
? _user!.fullName
|
||||||
|
: 'Assistante maternelle',
|
||||||
|
userEmail: _user?.email,
|
||||||
|
userRole: _user?.role,
|
||||||
|
onProfileTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Modification du profil – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettingsTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Paramètres – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLogout: () {},
|
||||||
|
showLogoutConfirmation: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Dashboard AM – à venir',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const AppFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/personal_info_form_screen.dart';
|
import '../../widgets/personal_info_form_screen.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
|
|
||||||
@@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||||
|
|
||||||
// Générer des données de test si vide
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
PersonalInfoData initialData;
|
PersonalInfoData initialData;
|
||||||
if (registrationData.firstName.isEmpty) {
|
if (registrationData.firstName.isEmpty) {
|
||||||
final genFirstName = DataGenerator.firstName();
|
|
||||||
final genLastName = DataGenerator.lastName();
|
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
firstName: genFirstName,
|
firstName: 'Marie',
|
||||||
lastName: genLastName,
|
lastName: 'DUBOIS',
|
||||||
phone: DataGenerator.phone(),
|
phone: '0696345678',
|
||||||
email: DataGenerator.email(genFirstName, genLastName),
|
email: 'marie.dubois@ptits-pas.fr',
|
||||||
address: DataGenerator.address(),
|
address: '25 Rue de la République',
|
||||||
postalCode: DataGenerator.postalCode(),
|
postalCode: '95870',
|
||||||
city: DataGenerator.city(),
|
city: 'Bezons',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/professional_info_form_screen.dart';
|
import '../../widgets/professional_info_form_screen.dart';
|
||||||
|
|
||||||
class AmRegisterStep2Screen extends StatefulWidget {
|
class AmRegisterStep2Screen extends StatefulWidget {
|
||||||
@@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
capacity: registrationData.capacity,
|
capacity: registrationData.capacity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Générer des données de test si les champs sont vides
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
||||||
initialData = ProfessionalInfoData(
|
initialData = ProfessionalInfoData(
|
||||||
photoPath: 'assets/images/icon_assmat.png',
|
photoPath: 'assets/images/icon_assmat.png',
|
||||||
photoConsent: true,
|
photoConsent: true,
|
||||||
dateOfBirth: DateTime(1985, 3, 15),
|
dateOfBirth: DateTime(1980, 6, 8),
|
||||||
birthCity: DataGenerator.city(),
|
birthCity: 'Bezons',
|
||||||
birthCountry: 'France',
|
birthCountry: 'France',
|
||||||
nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}',
|
nir: '280069512345671',
|
||||||
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
agrementNumber: 'AGR-2019-095001',
|
||||||
capacity: DataGenerator.randomIntInRange(1, 5),
|
capacity: 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
||||||
|
|
||||||
// Générer un texte de test si vide
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
String initialText = data.presentationText;
|
String initialText = data.presentationText;
|
||||||
bool initialCgu = data.cguAccepted;
|
bool initialCgu = data.cguAccepted;
|
||||||
|
|
||||||
if (initialText.isEmpty) {
|
if (initialText.isEmpty) {
|
||||||
initialText = 'Disponible immédiatement, plus de 10 ans d\'expérience avec les tout-petits. Formation aux premiers secours à jour. Je dispose d\'un jardin sécurisé et d\'un espace de jeu adapté.';
|
initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.';
|
||||||
initialCgu = true;
|
initialCgu = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -20,7 +21,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handlePasswordSubmitted(String _) {
|
||||||
|
if (_isLoading) return;
|
||||||
|
_handleLogin();
|
||||||
|
}
|
||||||
|
|
||||||
/// Gère la connexion de l'utilisateur
|
/// Gère la connexion de l'utilisateur
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
// Réinitialiser le message d'erreur
|
// Réinitialiser le message d'erreur
|
||||||
@@ -90,7 +96,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
// Vérifier si l'utilisateur doit changer son mot de passe
|
// Vérifier si l'utilisateur doit changer son mot de passe
|
||||||
if (user.changementMdpObligatoire) {
|
if (user.changementMdpObligatoire) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Afficher la modale de changement de mot de passe (non-dismissible)
|
// Afficher la modale de changement de mot de passe (non-dismissible)
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Laisse au navigateur/OS la possibilité de mémoriser les identifiants.
|
||||||
|
TextInput.finishAutofillContext(shouldSave: true);
|
||||||
|
|
||||||
// Rediriger selon le rôle de l'utilisateur
|
// Rediriger selon le rôle de l'utilisateur
|
||||||
_redirectUserByRole(user.role);
|
_redirectUserByRole(user.role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,9 +131,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
switch (role.toLowerCase()) {
|
switch (role.toLowerCase()) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'administrateur':
|
case 'administrateur':
|
||||||
case 'gestionnaire':
|
|
||||||
context.go('/admin-dashboard');
|
context.go('/admin-dashboard');
|
||||||
break;
|
break;
|
||||||
|
case 'gestionnaire':
|
||||||
|
context.go('/gestionnaire-dashboard');
|
||||||
|
break;
|
||||||
case 'parent':
|
case 'parent':
|
||||||
context.go('/parent-dashboard');
|
context.go('/parent-dashboard');
|
||||||
break;
|
break;
|
||||||
@@ -152,47 +163,49 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
return FutureBuilder(
|
return FutureBuilder(
|
||||||
future: _getImageDimensions(),
|
future: _getImageDimensions(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageDimensions = snapshot.data!;
|
final imageDimensions = snapshot.data!;
|
||||||
final imageHeight = h;
|
final imageHeight = h;
|
||||||
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height);
|
final imageWidth = imageHeight *
|
||||||
final remainingWidth = w - imageWidth;
|
(imageDimensions.width / imageDimensions.height);
|
||||||
final leftMargin = remainingWidth / 4;
|
final remainingWidth = w - imageWidth;
|
||||||
|
final leftMargin = remainingWidth / 4;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Fond en papier
|
// Fond en papier
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/paper2.png',
|
'assets/images/paper2.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
repeat: ImageRepeat.repeat,
|
repeat: ImageRepeat.repeat,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Image principale
|
),
|
||||||
Positioned(
|
// Image principale
|
||||||
left: leftMargin,
|
Positioned(
|
||||||
top: 0,
|
left: leftMargin,
|
||||||
height: imageHeight,
|
top: 0,
|
||||||
width: imageWidth,
|
height: imageHeight,
|
||||||
child: Image.asset(
|
width: imageWidth,
|
||||||
'assets/images/river_logo_desktop.png',
|
child: Image.asset(
|
||||||
fit: BoxFit.contain,
|
'assets/images/river_logo_desktop.png',
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
// Formulaire dans le cadran en bas à droite
|
),
|
||||||
Positioned(
|
// Formulaire dans le cadran en bas à droite
|
||||||
right: 0,
|
Positioned(
|
||||||
bottom: 0,
|
right: 0,
|
||||||
width: w * 0.6, // 60% de la largeur de l'écran
|
bottom: 0,
|
||||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
width: w * 0.6, // 60% de la largeur de l'écran
|
||||||
child: Padding(
|
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||||
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -207,6 +220,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
hintText: 'Votre adresse email',
|
hintText: 'Votre adresse email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
validator: _validateEmail,
|
validator: _validateEmail,
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -220,6 +239,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
labelText: 'Mot de passe',
|
labelText: 'Mot de passe',
|
||||||
hintText: 'Votre mot de passe',
|
hintText: 'Votre mot de passe',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.password
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted:
|
||||||
|
_handlePasswordSubmitted,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -229,7 +254,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Message d'erreur
|
// Message d'erreur
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Container(
|
Container(
|
||||||
@@ -242,7 +267,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red[700], size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -256,7 +282,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bouton centré
|
// Bouton centré
|
||||||
Center(
|
Center(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
@@ -309,67 +335,68 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
),
|
||||||
Positioned(
|
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
||||||
left: 0,
|
Positioned(
|
||||||
right: 0,
|
left: 0,
|
||||||
bottom: 0,
|
right: 0,
|
||||||
child: Container(
|
bottom: 0,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
child: Container(
|
||||||
decoration: const BoxDecoration(
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
color: Colors.transparent,
|
decoration: const BoxDecoration(
|
||||||
),
|
color: Colors.transparent,
|
||||||
child: Wrap(
|
),
|
||||||
alignment: WrapAlignment.center,
|
child: Wrap(
|
||||||
runSpacing: 8,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
runSpacing: 8,
|
||||||
_FooterLink(
|
children: [
|
||||||
text: 'Contact support',
|
_FooterLink(
|
||||||
onTap: () async {
|
text: 'Contact support',
|
||||||
final Uri emailLaunchUri = Uri(
|
onTap: () async {
|
||||||
scheme: 'mailto',
|
final Uri emailLaunchUri = Uri(
|
||||||
path: 'support@supernounou.local',
|
scheme: 'mailto',
|
||||||
);
|
path: 'support@supernounou.local',
|
||||||
if (await canLaunchUrl(emailLaunchUri)) {
|
);
|
||||||
await launchUrl(emailLaunchUri);
|
if (await canLaunchUrl(emailLaunchUri)) {
|
||||||
} else {
|
await launchUrl(emailLaunchUri);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
} else {
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
'Impossible d\'ouvrir le client mail',
|
content: Text(
|
||||||
style: GoogleFonts.merienda(),
|
'Impossible d\'ouvrir le client mail',
|
||||||
),
|
style: GoogleFonts.merienda(),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Signaler un bug',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Signaler un bug',
|
||||||
_showBugReportDialog(context);
|
onTap: () {
|
||||||
},
|
_showBugReportDialog(context);
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Mentions légales',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Mentions légales',
|
||||||
context.go('/legal');
|
onTap: () {
|
||||||
},
|
context.go('/legal');
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Politique de confidentialité',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Politique de confidentialité',
|
||||||
context.go('/privacy');
|
onTap: () {
|
||||||
},
|
context.go('/privacy');
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
},
|
);
|
||||||
);
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -378,6 +405,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
||||||
static const int _riverLogoMobileWidth = 600;
|
static const int _riverLogoMobileWidth = 600;
|
||||||
static const int _riverLogoMobileHeight = 1080;
|
static const int _riverLogoMobileHeight = 1080;
|
||||||
|
|
||||||
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
||||||
static const double _sloganEndFraction = 0.42;
|
static const double _sloganEndFraction = 0.42;
|
||||||
static const double _gapBelowSlogan = 12.0;
|
static const double _gapBelowSlogan = 12.0;
|
||||||
@@ -388,7 +416,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
||||||
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
final formTop =
|
||||||
|
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@@ -428,95 +457,115 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24, vertical: 20),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: Form(
|
child: AutofillGroup(
|
||||||
key: _formKey,
|
child: Form(
|
||||||
child: Column(
|
key: _formKey,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
CustomAppTextField(
|
const SizedBox(height: 16),
|
||||||
controller: _emailController,
|
CustomAppTextField(
|
||||||
labelText: 'Email',
|
controller: _emailController,
|
||||||
showLabel: false,
|
labelText: 'Email',
|
||||||
hintText: 'Votre adresse email',
|
showLabel: false,
|
||||||
validator: _validateEmail,
|
hintText: 'Votre adresse email',
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
keyboardType: TextInputType.emailAddress,
|
||||||
fieldHeight: 48,
|
autofillHints: const [
|
||||||
fieldWidth: double.infinity,
|
AutofillHints.username,
|
||||||
),
|
AutofillHints.email,
|
||||||
const SizedBox(height: 12),
|
],
|
||||||
CustomAppTextField(
|
textInputAction: TextInputAction.next,
|
||||||
controller: _passwordController,
|
validator: _validateEmail,
|
||||||
labelText: 'Mot de passe',
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
showLabel: false,
|
fieldHeight: 48,
|
||||||
hintText: 'Votre mot de passe',
|
fieldWidth: double.infinity,
|
||||||
obscureText: true,
|
),
|
||||||
validator: _validatePassword,
|
const SizedBox(height: 12),
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
CustomAppTextField(
|
||||||
fieldHeight: 48,
|
controller: _passwordController,
|
||||||
fieldWidth: double.infinity,
|
labelText: 'Mot de passe',
|
||||||
),
|
showLabel: false,
|
||||||
if (_errorMessage != null) ...[
|
hintText: 'Votre mot de passe',
|
||||||
const SizedBox(height: 12),
|
obscureText: true,
|
||||||
Container(
|
autofillHints: const [
|
||||||
padding: const EdgeInsets.all(12),
|
AutofillHints.password
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: Colors.red.shade50,
|
textInputAction: TextInputAction.done,
|
||||||
borderRadius: BorderRadius.circular(10),
|
onFieldSubmitted: _handlePasswordSubmitted,
|
||||||
border: Border.all(color: Colors.red.shade300),
|
validator: _validatePassword,
|
||||||
),
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
child: Row(
|
fieldHeight: 48,
|
||||||
children: [
|
fieldWidth: double.infinity,
|
||||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
),
|
||||||
const SizedBox(width: 10),
|
if (_errorMessage != null) ...[
|
||||||
Expanded(
|
const SizedBox(height: 12),
|
||||||
child: Text(
|
Container(
|
||||||
_errorMessage!,
|
padding: const EdgeInsets.all(12),
|
||||||
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
|
decoration: BoxDecoration(
|
||||||
),
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.red.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_isLoading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: ImageButton(
|
||||||
|
bg: 'assets/images/bg_green.png',
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
text: 'Se connecter',
|
||||||
|
textColor: const Color(0xFF2D6A4F),
|
||||||
|
onPressed: _handleLogin,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {/* TODO */},
|
||||||
|
child: Text(
|
||||||
|
'Mot de passe oublié ?',
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 14,
|
||||||
|
color: const Color(0xFF2D6A4F),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.go('/register-choice'),
|
||||||
|
child: Text(
|
||||||
|
'Créer un compte',
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 16,
|
||||||
|
color: const Color(0xFF2D6A4F),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_isLoading
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: ImageButton(
|
|
||||||
bg: 'assets/images/bg_green.png',
|
|
||||||
width: double.infinity,
|
|
||||||
height: 44,
|
|
||||||
text: 'Se connecter',
|
|
||||||
textColor: const Color(0xFF2D6A4F),
|
|
||||||
onPressed: _handleLogin,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () { /* TODO */ },
|
|
||||||
child: Text(
|
|
||||||
'Mot de passe oublié ?',
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 14,
|
|
||||||
color: const Color(0xFF2D6A4F),
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.go('/register-choice'),
|
|
||||||
child: Text(
|
|
||||||
'Créer un compte',
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 16,
|
|
||||||
color: const Color(0xFF2D6A4F),
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -533,12 +582,17 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
text: 'Contact support',
|
text: 'Contact support',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
|
final uri = Uri(
|
||||||
|
scheme: 'mailto',
|
||||||
|
path: 'support@supernounou.local');
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(uri);
|
await launchUrl(uri);
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Impossible d\'ouvrir le client mail',
|
||||||
|
style: GoogleFonts.merienda())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -707,4 +761,4 @@ class _FooterLink extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||||
|
|
||||||
|
/// Dashboard gestionnaire – même shell que l'admin, sans onglet Paramètres.
|
||||||
|
/// Réutilise [UserManagementPanel].
|
||||||
|
class GestionnaireDashboardScreen extends StatefulWidget {
|
||||||
|
const GestionnaireDashboardScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GestionnaireDashboardScreen> createState() =>
|
||||||
|
_GestionnaireDashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScreen> {
|
||||||
|
AppUser? _user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUser() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (mounted) setState(() => _user = user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
|
child: DashboardBandeau(
|
||||||
|
tabItems: const [
|
||||||
|
DashboardTabItem(label: 'Gestion des utilisateurs'),
|
||||||
|
],
|
||||||
|
selectedTabIndex: 0,
|
||||||
|
onTabSelected: (_) {},
|
||||||
|
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||||
|
? _user!.fullName
|
||||||
|
: 'Gestionnaire',
|
||||||
|
userEmail: _user?.email,
|
||||||
|
userRole: _user?.role,
|
||||||
|
onProfileTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Modification du profil – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettingsTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Paramètres – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLogout: () {},
|
||||||
|
showLogoutConfirmation: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: UserManagementPanel(showAdministrateursTab: false),
|
||||||
|
),
|
||||||
|
const AppFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/controllers/parent_dashboard_controller.dart';
|
import 'package:p_tits_pas/controllers/parent_dashboard_controller.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/dashboardService.dart';
|
import 'package:p_tits_pas/services/dashboardService.dart';
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/app_layout.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart';
|
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/dashboard_app_bar.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/wid_dashbord.dart';
|
import 'package:p_tits_pas/widgets/dashbord_parent/wid_dashbord.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||||
import 'package:p_tits_pas/widgets/main_content_area.dart';
|
import 'package:p_tits_pas/widgets/main_content_area.dart';
|
||||||
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -19,6 +20,7 @@ class ParentDashboardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
AppUser? _user;
|
||||||
|
|
||||||
void onTabChange(int index) {
|
void onTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -29,12 +31,18 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadUser();
|
||||||
// Initialiser les données du dashboard
|
// Initialiser les données du dashboard
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<ParentDashboardController>().initDashboard();
|
context.read<ParentDashboardController>().initDashboard();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUser() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (mounted) setState(() => _user = user);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -53,29 +61,43 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
|
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: PreferredSize(preferredSize: const Size.fromHeight(60.0),
|
appBar: PreferredSize(
|
||||||
child: Container(
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
decoration: BoxDecoration(
|
child: DashboardBandeau(
|
||||||
border: Border(
|
tabItems: const [
|
||||||
bottom: BorderSide(color: Colors.grey.shade300),
|
DashboardTabItem(label: 'Mon tableau de bord'),
|
||||||
),
|
DashboardTabItem(label: 'Trouver une nounou'),
|
||||||
),
|
DashboardTabItem(label: 'Paramètres'),
|
||||||
child: DashboardAppBar(
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedTabIndex: selectedIndex,
|
||||||
onTabChange: onTabChange,
|
onTabSelected: onTabChange,
|
||||||
|
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||||
|
? _user!.fullName
|
||||||
|
: 'Parent',
|
||||||
|
userEmail: _user?.email,
|
||||||
|
userRole: _user?.role,
|
||||||
|
onProfileTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Modification du profil – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettingsTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Paramètres – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLogout: () {},
|
||||||
|
showLogoutConfirmation: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded (child: _getBody(),
|
Expanded(child: _getBody()),
|
||||||
),
|
|
||||||
const AppFooter(),
|
const AppFooter(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
// body: _buildResponsiveBody(context, controller),
|
|
||||||
// footer: const AppFooter(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ class ApiConfig {
|
|||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
static const String relais = '/relais';
|
||||||
|
|
||||||
// Configuration (admin)
|
// Configuration (admin)
|
||||||
static const String configuration = '/configuration';
|
static const String configuration = '/configuration';
|
||||||
static const String configurationSetupStatus = '/configuration/setup/status';
|
static const String configurationSetupStatus = '/configuration/setup/status';
|
||||||
static const String configurationSetupComplete = '/configuration/setup/complete';
|
static const String configurationSetupComplete =
|
||||||
|
'/configuration/setup/complete';
|
||||||
static const String configurationTestSmtp = '/configuration/test-smtp';
|
static const String configurationTestSmtp = '/configuration/test-smtp';
|
||||||
static const String configurationBulk = '/configuration/bulk';
|
static const String configurationBulk = '/configuration/bulk';
|
||||||
|
|
||||||
@@ -33,14 +35,14 @@ class ApiConfig {
|
|||||||
static const String conversations = '/conversations';
|
static const String conversations = '/conversations';
|
||||||
static const String notifications = '/notifications';
|
static const String notifications = '/notifications';
|
||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
static Map<String, String> get headers => {
|
static Map<String, String> get headers => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, String> authHeaders(String token) => {
|
static Map<String, String> authHeaders(String token) => {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
|
class RelaisService {
|
||||||
|
static Future<Map<String, String>> _headers() async {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
return token != null
|
||||||
|
? ApiConfig.authHeaders(token)
|
||||||
|
: Map<String, String>.from(ApiConfig.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _extractError(String body, String fallback) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is String && message.trim().isNotEmpty) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<RelaisModel>> getRelais() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur chargement relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(RelaisModel.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RelaisModel> createRelais(Map<String, dynamic> payload) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur création relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelaisModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RelaisModel> updateRelais(
|
||||||
|
String id,
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur mise à jour relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelaisModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteRelais(String id) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur suppression relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,13 +29,87 @@ class UserService {
|
|||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
throw Exception(
|
||||||
|
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => AppUser.fromJson(e)).toList();
|
return data.map((e) => AppUser.fromJson(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createGestionnaire({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
String? relaisId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
'cguAccepted': true,
|
||||||
|
'relaisId': relaisId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création gestionnaire');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createAdministrateur({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des parents
|
// Récupérer la liste des parents
|
||||||
static Future<List<ParentModel>> getParents() async {
|
static Future<List<ParentModel>> getParents() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -53,7 +127,8 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
|
getAssistantesMaternelles() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
@@ -87,8 +162,212 @@ class UserService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur chargement admins: $e');
|
// On garde un fallback vide pour ne pas bloquer l'UI admin.
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createAdmin({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateAdmin({
|
||||||
|
required String adminId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String telephone,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'telephone': telephone,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateGestionnaireRelais({
|
||||||
|
required String gestionnaireId,
|
||||||
|
required String? relaisId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(
|
||||||
|
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateGestionnaire({
|
||||||
|
required String gestionnaireId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
String? telephone,
|
||||||
|
required String? relaisId,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'relaisId': relaisId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (telephone != null && telephone.trim().isNotEmpty) {
|
||||||
|
body['telephone'] = telephone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification gestionnaire');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification gestionnaire');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateAdministrateur({
|
||||||
|
required String adminId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
String? telephone,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (telephone != null && telephone.trim().isNotEmpty) {
|
||||||
|
body['telephone'] = telephone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteUser(String userId) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur suppression utilisateur');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AdminManagementWidget extends StatefulWidget {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
const AdminManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
|
||||||
|
const AdminManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
||||||
@@ -13,21 +22,17 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
List<AppUser> _filteredAdmins = [];
|
String? _currentUserRole;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadCurrentUserRole();
|
||||||
_loadAdmins();
|
_loadAdmins();
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAdmins() async {
|
Future<void> _loadAdmins() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -39,7 +44,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_admins = list;
|
_admins = list;
|
||||||
_filteredAdmins = list;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,91 +55,100 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged() {
|
Future<void> _loadCurrentUserRole() async {
|
||||||
final query = _searchController.text.toLowerCase();
|
final cached = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached != null) {
|
||||||
|
setState(() {
|
||||||
|
_currentUserRole = cached.role.toLowerCase();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final refreshed = await AuthService.refreshCurrentUser();
|
||||||
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_filteredAdmins = _admins.where((u) {
|
_currentUserRole = refreshed.role.toLowerCase();
|
||||||
final name = u.fullName.toLowerCase();
|
|
||||||
final email = u.email.toLowerCase();
|
|
||||||
return name.contains(query) || email.contains(query);
|
|
||||||
}).toList();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
|
bool _canEditAdmin(AppUser target) {
|
||||||
|
if (!_isSuperAdmin(target)) return true;
|
||||||
|
return _currentUserRole == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||||
|
final canEdit = _canEditAdmin(user);
|
||||||
|
final changed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminUserFormDialog(
|
||||||
|
initialUser: user,
|
||||||
|
adminMode: true,
|
||||||
|
withRelais: false,
|
||||||
|
readOnly: !canEdit,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (changed == true && canEdit) {
|
||||||
|
await _loadAdmins();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredAdmins = _admins.where((u) {
|
||||||
child: Column(
|
final name = u.fullName.toLowerCase();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final email = u.email.toLowerCase();
|
||||||
children: [
|
return name.contains(query) || email.contains(query);
|
||||||
Row(
|
}).toList();
|
||||||
children: [
|
|
||||||
Expanded(
|
return UserList(
|
||||||
child: TextField(
|
isLoading: _isLoading,
|
||||||
controller: _searchController,
|
error: _error,
|
||||||
decoration: const InputDecoration(
|
isEmpty: filteredAdmins.isEmpty,
|
||||||
hintText: "Rechercher un administrateur...",
|
emptyMessage: 'Aucun administrateur trouvé.',
|
||||||
prefixIcon: Icon(Icons.search),
|
itemCount: filteredAdmins.length,
|
||||||
border: OutlineInputBorder(),
|
itemBuilder: (context, index) {
|
||||||
),
|
final user = filteredAdmins[index];
|
||||||
),
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
|
final canEdit = _canEditAdmin(user);
|
||||||
|
return AdminUserCard(
|
||||||
|
title: user.fullName,
|
||||||
|
fallbackIcon: isSuperAdmin
|
||||||
|
? Icons.verified_user_outlined
|
||||||
|
: Icons.manage_accounts_outlined,
|
||||||
|
subtitleLines: [
|
||||||
|
user.email,
|
||||||
|
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
|
||||||
|
],
|
||||||
|
avatarUrl: user.photoUrl,
|
||||||
|
borderColor: isSuperAdmin
|
||||||
|
? const Color(0xFF8E6AC8)
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
backgroundColor: isSuperAdmin
|
||||||
|
? const Color(0xFFF4EEFF)
|
||||||
|
: Colors.white,
|
||||||
|
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
|
||||||
|
infoColor: isSuperAdmin
|
||||||
|
? const Color(0xFF6D4EA1)
|
||||||
|
: Colors.black54,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
tooltip: canEdit ? 'Modifier' : 'Consulter',
|
||||||
ElevatedButton.icon(
|
onPressed: () {
|
||||||
onPressed: () {
|
_openAdminEditDialog(user);
|
||||||
// TODO: Créer admin
|
},
|
||||||
},
|
),
|
||||||
icon: const Icon(Icons.add),
|
],
|
||||||
label: const Text("Créer un admin"),
|
);
|
||||||
),
|
},
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_isLoading)
|
|
||||||
const Center(child: CircularProgressIndicator())
|
|
||||||
else if (_error != null)
|
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
|
||||||
else if (_filteredAdmins.isEmpty)
|
|
||||||
const Center(child: Text("Aucun administrateur trouvé."))
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filteredAdmins.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final user = _filteredAdmins[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
child: Text(user.fullName.isNotEmpty
|
|
||||||
? user.fullName[0].toUpperCase()
|
|
||||||
: 'A'),
|
|
||||||
),
|
|
||||||
title: Text(user.fullName.isNotEmpty
|
|
||||||
? user.fullName
|
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(user.email),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
const AssistanteMaternelleManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final int? capacityMin;
|
||||||
|
|
||||||
|
const AssistanteMaternelleManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.capacityMin,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssistanteMaternelleManagementWidget> createState() =>
|
State<AssistanteMaternelleManagementWidget> createState() =>
|
||||||
@@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AssistanteMaternelleModel> _assistantes = [];
|
List<AssistanteMaternelleModel> _assistantes = [];
|
||||||
List<AssistanteMaternelleModel> _filteredAssistantes = [];
|
|
||||||
|
|
||||||
final TextEditingController _zoneController = TextEditingController();
|
|
||||||
final TextEditingController _capacityController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAssistantes();
|
_loadAssistantes();
|
||||||
_zoneController.addListener(_filter);
|
|
||||||
_capacityController.addListener(_filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_zoneController.dispose();
|
|
||||||
_capacityController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAssistantes() async {
|
Future<void> _loadAssistantes() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_assistantes = list;
|
_assistantes = list;
|
||||||
_filter();
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -57,117 +56,100 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filter() {
|
|
||||||
final zoneQuery = _zoneController.text.toLowerCase();
|
|
||||||
final capacityQuery = int.tryParse(_capacityController.text);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_filteredAssistantes = _assistantes.where((am) {
|
|
||||||
final matchesZone = zoneQuery.isEmpty ||
|
|
||||||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
|
|
||||||
final matchesCapacity = capacityQuery == null ||
|
|
||||||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
|
|
||||||
return matchesZone && matchesCapacity;
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredAssistantes = _assistantes.where((am) {
|
||||||
child: Column(
|
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
am.user.email.toLowerCase().contains(query) ||
|
||||||
children: [
|
(am.residenceCity?.toLowerCase().contains(query) ?? false);
|
||||||
// 🔎 Zone de filtre
|
final matchesCapacity = widget.capacityMin == null ||
|
||||||
_buildFilterSection(),
|
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
|
||||||
|
return matchesName && matchesCapacity;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
// 📋 Liste des assistantes
|
error: _error,
|
||||||
if (_isLoading)
|
isEmpty: filteredAssistantes.isEmpty,
|
||||||
const Center(child: CircularProgressIndicator())
|
emptyMessage: 'Aucune assistante maternelle trouvée.',
|
||||||
else if (_error != null)
|
itemCount: filteredAssistantes.length,
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
itemBuilder: (context, index) {
|
||||||
else if (_filteredAssistantes.isEmpty)
|
final assistante = filteredAssistantes[index];
|
||||||
const Center(child: Text("Aucune assistante maternelle trouvée."))
|
return AdminUserCard(
|
||||||
else
|
title: assistante.user.fullName,
|
||||||
Expanded(
|
avatarUrl: assistante.user.photoUrl,
|
||||||
child: ListView.builder(
|
fallbackIcon: Icons.face,
|
||||||
itemCount: _filteredAssistantes.length,
|
subtitleLines: [
|
||||||
itemBuilder: (context, index) {
|
assistante.user.email,
|
||||||
final assistante = _filteredAssistantes[index];
|
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
||||||
return Card(
|
],
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
actions: [
|
||||||
child: ListTile(
|
IconButton(
|
||||||
leading: CircleAvatar(
|
icon: const Icon(Icons.edit),
|
||||||
backgroundImage: assistante.user.photoUrl != null
|
tooltip: 'Modifier',
|
||||||
? NetworkImage(assistante.user.photoUrl!)
|
onPressed: () {
|
||||||
: null,
|
_openAssistanteDetails(assistante);
|
||||||
child: assistante.user.photoUrl == null
|
},
|
||||||
? const Icon(Icons.face)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(assistante.user.fullName.isNotEmpty
|
|
||||||
? assistante.user.fullName
|
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(
|
|
||||||
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter modification
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter suppression
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AdminDetailModal(
|
||||||
|
title: assistante.user.fullName.isEmpty
|
||||||
|
? 'Assistante maternelle'
|
||||||
|
: assistante.user.fullName,
|
||||||
|
subtitle: assistante.user.email,
|
||||||
|
fields: [
|
||||||
|
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Numero agrement',
|
||||||
|
value: _v(assistante.approvalNumber),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Ville residence',
|
||||||
|
value: _v(assistante.residenceCity),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Capacite max',
|
||||||
|
value: assistante.maxChildren?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: assistante.placesAvailable?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Telephone',
|
||||||
|
value: _v(assistante.user.telephone),
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
||||||
|
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(assistante.user.codePostal),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterSection() {
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
return Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 200,
|
|
||||||
child: TextField(
|
|
||||||
controller: _zoneController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Zone géographique",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.location_on),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 200,
|
|
||||||
child: TextField(
|
|
||||||
controller: _capacityController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Capacité minimum",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminDetailField {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const AdminDetailField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminDetailModal extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final List<AdminDetailField> fields;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const AdminDetailModal({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.fields,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 620),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: fields
|
||||||
|
.map(
|
||||||
|
(field) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: Text(
|
||||||
|
field.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
field.value,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Supprimer'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red.shade700,
|
||||||
|
side: BorderSide(color: Colors.red.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text('Modifier'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminListState extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final bool isEmpty;
|
||||||
|
final String emptyMessage;
|
||||||
|
final Widget list;
|
||||||
|
|
||||||
|
const AdminListState({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.error,
|
||||||
|
required this.isEmpty,
|
||||||
|
required this.emptyMessage,
|
||||||
|
required this.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Expanded(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Erreur: $error',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(emptyMessage),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expanded(child: list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminUserCard extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final List<String> subtitleLines;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final IconData fallbackIcon;
|
||||||
|
final List<Widget> actions;
|
||||||
|
final Color? borderColor;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? titleColor;
|
||||||
|
final Color? infoColor;
|
||||||
|
|
||||||
|
const AdminUserCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitleLines,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.fallbackIcon = Icons.person,
|
||||||
|
this.actions = const [],
|
||||||
|
this.borderColor,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.titleColor,
|
||||||
|
this.infoColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminUserCard> createState() => _AdminUserCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUserCardState extends State<AdminUserCard> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final infoLine =
|
||||||
|
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
|
||||||
|
final actionsWidth =
|
||||||
|
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
hoverColor: const Color(0x149CC5C0),
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: const Color(0xFFEDE5FA),
|
||||||
|
backgroundImage: widget.avatarUrl != null
|
||||||
|
? NetworkImage(widget.avatarUrl!)
|
||||||
|
: null,
|
||||||
|
child: widget.avatarUrl == null
|
||||||
|
? Icon(
|
||||||
|
widget.fallbackIcon,
|
||||||
|
size: 16,
|
||||||
|
color: const Color(0xFF6B3FA0),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: Text(
|
||||||
|
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
).copyWith(color: widget.titleColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
infoLine,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 12,
|
||||||
|
).copyWith(color: widget.infoColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.actions.isNotEmpty)
|
||||||
|
SizedBox(
|
||||||
|
width: actionsWidth,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
opacity: _isHovered ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isHovered,
|
||||||
|
child: IconTheme(
|
||||||
|
data: const IconThemeData(size: 17),
|
||||||
|
child: IconButtonTheme(
|
||||||
|
data: IconButtonThemeData(
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
minimumSize: const Size(28, 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.actions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
|
||||||
|
|
||||||
|
class UserList extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final bool isEmpty;
|
||||||
|
final String emptyMessage;
|
||||||
|
final int itemCount;
|
||||||
|
final Widget Function(BuildContext context, int index) itemBuilder;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const UserList({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.error,
|
||||||
|
required this.isEmpty,
|
||||||
|
required this.emptyMessage,
|
||||||
|
required this.itemCount,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.padding = const EdgeInsets.all(16),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AdminListState(
|
||||||
|
isLoading: isLoading,
|
||||||
|
error: error,
|
||||||
|
isEmpty: isEmpty,
|
||||||
|
emptyMessage: emptyMessage,
|
||||||
|
list: ListView.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: itemBuilder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,140 +1,131 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
|
||||||
|
|
||||||
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
|
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||||
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
|
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
|
||||||
final int selectedIndex;
|
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||||
final ValueChanged<int> onTabChange;
|
final int selectedSubIndex;
|
||||||
final bool setupCompleted;
|
final ValueChanged<int> onSubTabChange;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final String searchHint;
|
||||||
|
final Widget? filterControl;
|
||||||
|
final VoidCallback? onAddPressed;
|
||||||
|
final String addLabel;
|
||||||
|
final int subTabCount;
|
||||||
|
|
||||||
const DashboardAppBarAdmin({
|
static const List<String> _tabLabels = [
|
||||||
|
'Gestionnaires',
|
||||||
|
'Parents',
|
||||||
|
'Assistantes maternelles',
|
||||||
|
'Administrateurs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DashboardUserManagementSubBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onTabChange,
|
required this.onSubTabChange,
|
||||||
this.setupCompleted = true,
|
required this.searchController,
|
||||||
|
required this.searchHint,
|
||||||
|
this.filterControl,
|
||||||
|
this.onAddPressed,
|
||||||
|
this.addLabel = '+ Ajouter',
|
||||||
|
this.subTabCount = 4,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppBar(
|
return Container(
|
||||||
elevation: 0,
|
height: 56,
|
||||||
automaticallyImplyLeading: false,
|
decoration: BoxDecoration(
|
||||||
title: Row(
|
color: Colors.grey.shade100,
|
||||||
|
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 24),
|
for (int i = 0; i < subTabCount; i++) ...[
|
||||||
Image.asset(
|
if (i > 0) const SizedBox(width: 12),
|
||||||
'assets/images/logo.png',
|
_buildSubNavItem(context, _tabLabels[i], i),
|
||||||
height: 40,
|
],
|
||||||
fit: BoxFit.contain,
|
const SizedBox(width: 36),
|
||||||
),
|
_pillField(
|
||||||
Expanded(
|
width: 320,
|
||||||
child: Center(
|
child: TextField(
|
||||||
child: Row(
|
controller: searchController,
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: InputDecoration(
|
||||||
children: [
|
hintText: searchHint,
|
||||||
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
const SizedBox(width: 24),
|
border: InputBorder.none,
|
||||||
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
isDense: true,
|
||||||
],
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (filterControl != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_pillField(width: 150, child: filterControl!),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
_buildAddButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Admin',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => _handleLogout(context),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF9CC5C0),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Se déconnecter'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
|
Widget _pillField({required double width, required Widget child}) {
|
||||||
final bool isActive = index == selectedIndex;
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: Colors.black26),
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddButton() {
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
onPressed: onAddPressed,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(addLabel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubNavItem(BuildContext context, String title, int index) {
|
||||||
|
final bool isActive = index == selectedSubIndex;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: enabled ? () => onTabChange(index) : null,
|
onTap: () => onSubTabChange(index),
|
||||||
child: Opacity(
|
child: Container(
|
||||||
opacity: enabled ? 1.0 : 0.5,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
border: isActive ? null : Border.all(color: Colors.black26),
|
||||||
borderRadius: BorderRadius.circular(20),
|
),
|
||||||
border: isActive ? null : Border.all(color: Colors.black26),
|
child: Text(
|
||||||
),
|
title,
|
||||||
child: Text(
|
style: TextStyle(
|
||||||
title,
|
color: isActive ? Colors.white : Colors.black87,
|
||||||
style: TextStyle(
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
color: isActive ? Colors.white : Colors.black,
|
fontSize: 13,
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLogout(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Déconnexion'),
|
|
||||||
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await AuthService.logout();
|
|
||||||
if (context.mounted) context.go('/login');
|
|
||||||
},
|
|
||||||
child: const Text('Déconnecter'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
|
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
|
||||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
class DashboardSettingsSubBar extends StatelessWidget {
|
||||||
final int selectedSubIndex;
|
final int selectedSubIndex;
|
||||||
final ValueChanged<int> onSubTabChange;
|
final ValueChanged<int> onSubTabChange;
|
||||||
|
|
||||||
const DashboardUserManagementSubBar({
|
const DashboardSettingsSubBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedSubIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onSubTabChange,
|
required this.onSubTabChange,
|
||||||
@@ -153,13 +144,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildSubNavItem(context, 'Gestionnaires', 0),
|
_buildSubNavItem(context, 'Paramètres généraux', 0),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildSubNavItem(context, 'Parents', 1),
|
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildSubNavItem(context, 'Assistantes maternelles', 2),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildSubNavItem(context, 'Administrateurs', 3),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class GestionnaireCard extends StatelessWidget {
|
|
||||||
final String name;
|
|
||||||
final String email;
|
|
||||||
|
|
||||||
const GestionnaireCard({
|
|
||||||
Key? key,
|
|
||||||
required this.name,
|
|
||||||
required this.email,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 🔹 Infos principales
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text(email, style: const TextStyle(color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 🔹 Attribution à des RPE (dropdown fictif ici)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Text("RPE attribué : "),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: "RPE 1",
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")),
|
|
||||||
DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")),
|
|
||||||
DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")),
|
|
||||||
],
|
|
||||||
onChanged: (value) {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 🔹 Boutons d'action
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Réinitialisation mot de passe
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.lock_reset),
|
|
||||||
label: const Text("Réinitialiser MDP"),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Suppression du compte
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
label: const Text("Supprimer", style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatefulWidget {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
const GestionnaireManagementWidget({Key? key}) : super(key: key);
|
final String searchQuery;
|
||||||
|
|
||||||
|
const GestionnaireManagementWidget({
|
||||||
|
Key? key,
|
||||||
|
required this.searchQuery,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GestionnaireManagementWidget> createState() =>
|
State<GestionnaireManagementWidget> createState() =>
|
||||||
@@ -16,21 +23,15 @@ class _GestionnaireManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _gestionnaires = [];
|
List<AppUser> _gestionnaires = [];
|
||||||
List<AppUser> _filteredGestionnaires = [];
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadGestionnaires();
|
_loadGestionnaires();
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadGestionnaires() async {
|
Future<void> _loadGestionnaires() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -38,11 +39,10 @@ class _GestionnaireManagementWidgetState
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final list = await UserService.getGestionnaires();
|
final gestionnaires = await UserService.getGestionnaires();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_gestionnaires = list;
|
_gestionnaires = gestionnaires;
|
||||||
_filteredGestionnaires = list;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -54,71 +54,56 @@ class _GestionnaireManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged() {
|
Future<void> _openGestionnaireEditDialog(AppUser user) async {
|
||||||
final query = _searchController.text.toLowerCase();
|
final changed = await showDialog<bool>(
|
||||||
setState(() {
|
context: context,
|
||||||
_filteredGestionnaires = _gestionnaires.where((u) {
|
barrierDismissible: false,
|
||||||
final name = u.fullName.toLowerCase();
|
builder: (dialogContext) {
|
||||||
final email = u.email.toLowerCase();
|
return AdminUserFormDialog(initialUser: user);
|
||||||
return name.contains(query) || email.contains(query);
|
},
|
||||||
}).toList();
|
);
|
||||||
});
|
if (changed == true) {
|
||||||
|
await _loadGestionnaires();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredGestionnaires = _gestionnaires.where((u) {
|
||||||
child: Column(
|
final name = u.fullName.toLowerCase();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final email = u.email.toLowerCase();
|
||||||
children: [
|
return name.contains(query) || email.contains(query);
|
||||||
// 🔹 Barre du haut avec bouton
|
}).toList();
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: "Rechercher un gestionnaire...",
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Rediriger vers la page de création
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Créer un gestionnaire"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 🔹 Liste des gestionnaires
|
return UserList(
|
||||||
if (_isLoading)
|
isLoading: _isLoading,
|
||||||
const Center(child: CircularProgressIndicator())
|
error: _error,
|
||||||
else if (_error != null)
|
isEmpty: filteredGestionnaires.isEmpty,
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
emptyMessage: 'Aucun gestionnaire trouvé.',
|
||||||
else if (_filteredGestionnaires.isEmpty)
|
itemCount: filteredGestionnaires.length,
|
||||||
const Center(child: Text("Aucun gestionnaire trouvé."))
|
itemBuilder: (context, index) {
|
||||||
else
|
final user = filteredGestionnaires[index];
|
||||||
Expanded(
|
return AdminUserCard(
|
||||||
child: ListView.builder(
|
title: user.fullName,
|
||||||
itemCount: _filteredGestionnaires.length,
|
fallbackIcon: Icons.assignment_ind_outlined,
|
||||||
itemBuilder: (context, index) {
|
avatarUrl: user.photoUrl,
|
||||||
final user = _filteredGestionnaires[index];
|
subtitleLines: [
|
||||||
return GestionnaireCard(
|
user.email,
|
||||||
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
|
'Statut : ${user.statut ?? 'Inconnu'}',
|
||||||
email: user.email,
|
'Relais : ${user.relaisNom ?? 'Non rattaché'}',
|
||||||
);
|
],
|
||||||
},
|
actions: [
|
||||||
),
|
IconButton(
|
||||||
)
|
icon: const Icon(Icons.edit),
|
||||||
],
|
tooltip: 'Modifier',
|
||||||
),
|
onPressed: () {
|
||||||
|
_openGestionnaireEditDialog(user);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
|
||||||
|
|
||||||
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
||||||
class ParametresPanel extends StatefulWidget {
|
class ParametresPanel extends StatefulWidget {
|
||||||
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
|
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
|
||||||
final bool redirectToLoginAfterSave;
|
final bool redirectToLoginAfterSave;
|
||||||
|
final int selectedSettingsTabIndex;
|
||||||
|
|
||||||
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
|
const ParametresPanel({
|
||||||
|
super.key,
|
||||||
|
this.redirectToLoginAfterSave = false,
|
||||||
|
this.selectedSettingsTabIndex = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParametresPanel> createState() => _ParametresPanelState();
|
State<ParametresPanel> createState() => _ParametresPanelState();
|
||||||
@@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
|
|
||||||
void _createControllers() {
|
void _createControllers() {
|
||||||
final keys = [
|
final keys = [
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
|
'smtp_host',
|
||||||
'email_from_name', 'email_from_address',
|
'smtp_port',
|
||||||
'app_name', 'app_url', 'app_logo_url',
|
'smtp_user',
|
||||||
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
|
'smtp_password',
|
||||||
|
'email_from_name',
|
||||||
|
'email_from_address',
|
||||||
|
'app_name',
|
||||||
|
'app_url',
|
||||||
|
'app_logo_url',
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
'jwt_expiry_hours',
|
||||||
|
'max_upload_size_mb',
|
||||||
];
|
];
|
||||||
for (final k in keys) {
|
for (final k in keys) {
|
||||||
_controllers[k] = TextEditingController();
|
_controllers[k] = TextEditingController();
|
||||||
@@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
payload['smtp_auth_required'] = _smtpAuthRequired;
|
payload['smtp_auth_required'] = _smtpAuthRequired;
|
||||||
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
|
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
|
||||||
final pwd = _controllers['smtp_password']!.text.trim();
|
final pwd = _controllers['smtp_password']!.text.trim();
|
||||||
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
|
if (pwd.isNotEmpty && pwd != '***********') {
|
||||||
|
payload['smtp_password'] = pwd;
|
||||||
|
}
|
||||||
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
|
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
|
||||||
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
|
payload['email_from_address'] =
|
||||||
|
_controllers['email_from_address']!.text.trim();
|
||||||
payload['app_name'] = _controllers['app_name']!.text.trim();
|
payload['app_name'] = _controllers['app_name']!.text.trim();
|
||||||
payload['app_url'] = _controllers['app_url']!.text.trim();
|
payload['app_url'] = _controllers['app_url']!.text.trim();
|
||||||
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
|
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
|
||||||
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
|
final tokenDays = int.tryParse(
|
||||||
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
|
_controllers['password_reset_token_expiry_days']!.text.trim());
|
||||||
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
if (tokenDays != null) {
|
||||||
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
|
payload['password_reset_token_expiry_days'] = tokenDays;
|
||||||
|
}
|
||||||
|
final jwtHours =
|
||||||
|
int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
||||||
|
if (jwtHours != null) {
|
||||||
|
payload['jwt_expiry_hours'] = jwtHours;
|
||||||
|
}
|
||||||
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
|
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
|
||||||
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
|
if (maxMb != null) {
|
||||||
|
payload['max_upload_size_mb'] = maxMb;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.selectedSettingsTabIndex == 1) {
|
||||||
|
return const RelaisManagementPanel();
|
||||||
|
}
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isSuccess = _message != null &&
|
final isSuccess = _message != null &&
|
||||||
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
|
(_message!.startsWith('Configuration') ||
|
||||||
|
_message!.startsWith('Connexion'));
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -234,12 +264,21 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
title: 'Configuration Email (SMTP)',
|
title: 'Configuration Email (SMTP)',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
|
_buildField(
|
||||||
|
'smtp_host',
|
||||||
|
'Serveur SMTP',
|
||||||
|
hint: 'mail.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
|
_buildField(
|
||||||
|
'smtp_port',
|
||||||
|
'Port SMTP',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
hint: '25, 465, 587',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
@@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpSecure,
|
value: _smtpSecure,
|
||||||
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
|
onChanged: (v) =>
|
||||||
|
setState(() => _smtpSecure = v ?? false),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('SSL/TLS (secure)'),
|
const Text('SSL/TLS (secure)'),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpAuthRequired,
|
value: _smtpAuthRequired,
|
||||||
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
|
onChanged: (v) => setState(
|
||||||
|
() => _smtpAuthRequired = v ?? false,
|
||||||
|
),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('Authentification requise'),
|
const Text('Authentification requise'),
|
||||||
@@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
),
|
),
|
||||||
_buildField('smtp_user', 'Utilisateur SMTP'),
|
_buildField('smtp_user', 'Utilisateur SMTP'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
|
_buildField(
|
||||||
|
'smtp_password',
|
||||||
|
'Mot de passe SMTP',
|
||||||
|
obscure: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_name', 'Nom expéditeur'),
|
_buildField('email_from_name', 'Nom expéditeur'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
|
_buildField(
|
||||||
|
'email_from_address',
|
||||||
|
'Email expéditeur',
|
||||||
|
hint: 'no-reply@example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
@@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
label: const Text('Tester la connexion SMTP'),
|
label: const Text('Tester la connexion SMTP'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF2D6A4F),
|
foregroundColor: const Color(0xFF2D6A4F),
|
||||||
side: const BorderSide(color: Color(0xFF9CC5C0)),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
color: Color(0xFF9CC5C0),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -290,14 +345,22 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
title: 'Personnalisation',
|
title: 'Personnalisation',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('app_name', 'Nom de l\'application'),
|
_buildField('app_name', 'Nom de l\'application'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
|
_buildField(
|
||||||
|
'app_url',
|
||||||
|
'URL de l\'application',
|
||||||
|
hint: 'https://app.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
|
_buildField(
|
||||||
|
'app_logo_url',
|
||||||
|
'URL du logo',
|
||||||
|
hint: '/assets/logo.png',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
'Validité token MDP (jours)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'jwt_expiry_hours',
|
||||||
|
'Validité session JWT (heures)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'max_upload_size_mb',
|
||||||
|
'Taille max upload (MB)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,7 +402,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
: const Text('Sauvegarder la configuration'),
|
: const Text('Sauvegarder la configuration'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -339,7 +421,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
|
Widget _buildSectionCard(BuildContext context,
|
||||||
|
{required IconData icon, required String title, required Widget child}) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
@@ -369,7 +452,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
|
Widget _buildField(String key, String label,
|
||||||
|
{bool obscure = false, TextInputType? keyboard, String? hint}) {
|
||||||
final c = _controllers[key];
|
final c = _controllers[key];
|
||||||
if (c == null) return const SizedBox.shrink();
|
if (c == null) return const SizedBox.shrink();
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
@@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class ParentManagementWidget extends StatefulWidget {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
const ParentManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final String? statusFilter;
|
||||||
|
|
||||||
|
const ParentManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.statusFilter,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
||||||
@@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<ParentModel> _parents = [];
|
List<ParentModel> _parents = [];
|
||||||
List<ParentModel> _filteredParents = [];
|
|
||||||
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
String? _selectedStatus;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadParents();
|
_loadParents();
|
||||||
_searchController.addListener(_filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadParents() async {
|
Future<void> _loadParents() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_parents = list;
|
_parents = list;
|
||||||
_filter(); // Apply initial filter (if any)
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -53,139 +54,102 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filter() {
|
|
||||||
final query = _searchController.text.toLowerCase();
|
|
||||||
setState(() {
|
|
||||||
_filteredParents = _parents.where((p) {
|
|
||||||
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
|
||||||
p.user.email.toLowerCase().contains(query);
|
|
||||||
final matchesStatus = _selectedStatus == null ||
|
|
||||||
_selectedStatus == 'Tous' ||
|
|
||||||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
|
|
||||||
|
|
||||||
// Mapping simple pour le statut affiché vs backend
|
|
||||||
// Backend: en_attente, actif, suspendu
|
|
||||||
// Dropdown: En attente, Actif, Suspendu
|
|
||||||
|
|
||||||
return matchesName && matchesStatus;
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredParents = _parents.where((p) {
|
||||||
child: Column(
|
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
p.user.email.toLowerCase().contains(query);
|
||||||
children: [
|
final matchesStatus =
|
||||||
_buildSearchSection(),
|
widget.statusFilter == null || p.user.statut == widget.statusFilter;
|
||||||
const SizedBox(height: 16),
|
return matchesName && matchesStatus;
|
||||||
if (_isLoading)
|
}).toList();
|
||||||
const Center(child: CircularProgressIndicator())
|
|
||||||
else if (_error != null)
|
return UserList(
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
isLoading: _isLoading,
|
||||||
else if (_filteredParents.isEmpty)
|
error: _error,
|
||||||
const Center(child: Text("Aucun parent trouvé."))
|
isEmpty: filteredParents.isEmpty,
|
||||||
else
|
emptyMessage: 'Aucun parent trouvé.',
|
||||||
Expanded(
|
itemCount: filteredParents.length,
|
||||||
child: ListView.builder(
|
itemBuilder: (context, index) {
|
||||||
itemCount: _filteredParents.length,
|
final parent = filteredParents[index];
|
||||||
itemBuilder: (context, index) {
|
return AdminUserCard(
|
||||||
final parent = _filteredParents[index];
|
title: parent.user.fullName,
|
||||||
return Card(
|
fallbackIcon: Icons.supervisor_account_outlined,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
avatarUrl: parent.user.photoUrl,
|
||||||
child: ListTile(
|
subtitleLines: [
|
||||||
leading: CircleAvatar(
|
parent.user.email,
|
||||||
backgroundImage: parent.user.photoUrl != null
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
|
||||||
? NetworkImage(parent.user.photoUrl!)
|
],
|
||||||
: null,
|
actions: [
|
||||||
child: parent.user.photoUrl == null
|
IconButton(
|
||||||
? const Icon(Icons.person)
|
icon: const Icon(Icons.edit),
|
||||||
: null,
|
tooltip: 'Modifier',
|
||||||
),
|
onPressed: () {
|
||||||
title: Text(parent.user.fullName.isNotEmpty
|
_openParentDetails(parent);
|
||||||
? parent.user.fullName
|
},
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(
|
|
||||||
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.visibility),
|
|
||||||
tooltip: "Voir dossier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Voir le statut du dossier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
tooltip: "Modifier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Modifier parent
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: "Supprimer",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Supprimer compte
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayStatus(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'en_attente':
|
||||||
|
return 'En attente';
|
||||||
|
case 'suspendu':
|
||||||
|
return 'Suspendu';
|
||||||
|
default:
|
||||||
|
return 'Inconnu';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openParentDetails(ParentModel parent) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AdminDetailModal(
|
||||||
|
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
|
||||||
|
subtitle: parent.user.email,
|
||||||
|
fields: [
|
||||||
|
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Statut',
|
||||||
|
value: _displayStatus(parent.user.statut),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Telephone',
|
||||||
|
value: _v(parent.user.telephone),
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
||||||
|
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(parent.user.codePostal),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Nombre d\'enfants',
|
||||||
|
value: parent.childrenCount.toString(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchSection() {
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
return Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Nom du parent",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Statut",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
value: _selectedStatus,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: null, child: Text("Tous")),
|
|
||||||
DropdownMenuItem(value: "actif", child: Text("Actif")),
|
|
||||||
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
|
|
||||||
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedStatus = value;
|
|
||||||
_filter();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||||
|
|
||||||
|
class UserManagementPanel extends StatefulWidget {
|
||||||
|
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
||||||
|
final bool showAdministrateursTab;
|
||||||
|
|
||||||
|
const UserManagementPanel({
|
||||||
|
super.key,
|
||||||
|
this.showAdministrateursTab = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserManagementPanel> createState() => _UserManagementPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||||
|
int _subIndex = 0;
|
||||||
|
int _gestionnaireRefreshTick = 0;
|
||||||
|
int _adminRefreshTick = 0;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
|
String? _parentStatus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController.addListener(_onFilterChanged);
|
||||||
|
_amCapacityController.addListener(_onFilterChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.removeListener(_onFilterChanged);
|
||||||
|
_amCapacityController.removeListener(_onFilterChanged);
|
||||||
|
_searchController.dispose();
|
||||||
|
_amCapacityController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFilterChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSubTabChange(int index) {
|
||||||
|
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
|
||||||
|
setState(() {
|
||||||
|
_subIndex = index.clamp(0, maxIndex);
|
||||||
|
_searchController.clear();
|
||||||
|
_parentStatus = null;
|
||||||
|
_amCapacityController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _searchHintForTab() {
|
||||||
|
switch (_subIndex) {
|
||||||
|
case 0:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
|
case 1:
|
||||||
|
return 'Rechercher un parent...';
|
||||||
|
case 2:
|
||||||
|
return 'Rechercher une assistante...';
|
||||||
|
case 3:
|
||||||
|
return 'Rechercher un administrateur...';
|
||||||
|
default:
|
||||||
|
return 'Rechercher...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _subBarFilterControl() {
|
||||||
|
if (_subIndex == 1) {
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: _parentStatus,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Statut', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Tous', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'actif',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Actif', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'en_attente',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('En attente', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'suspendu',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Suspendu', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_parentStatus = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_subIndex == 2) {
|
||||||
|
return TextField(
|
||||||
|
controller: _amCapacityController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Capacité min',
|
||||||
|
hintStyle: TextStyle(fontSize: 12),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
switch (_subIndex) {
|
||||||
|
case 0:
|
||||||
|
return GestionnaireManagementWidget(
|
||||||
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return ParentManagementWidget(
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
statusFilter: _parentStatus,
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return AssistanteMaternelleManagementWidget(
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return AdminManagementWidget(
|
||||||
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const Center(child: Text('Page non trouvée'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
DashboardUserManagementSubBar(
|
||||||
|
selectedSubIndex: _subIndex,
|
||||||
|
onSubTabChange: _onSubTabChange,
|
||||||
|
searchController: _searchController,
|
||||||
|
searchHint: _searchHintForTab(),
|
||||||
|
filterControl: _subBarFilterControl(),
|
||||||
|
onAddPressed: _handleAddPressed,
|
||||||
|
addLabel: 'Ajouter',
|
||||||
|
subTabCount: subTabCount,
|
||||||
|
),
|
||||||
|
Expanded(child: _buildBody()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAddPressed() async {
|
||||||
|
if (_subIndex == 0) {
|
||||||
|
final created = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return const AdminUserFormDialog();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
if (created == true) {
|
||||||
|
setState(() {
|
||||||
|
_gestionnaireRefreshTick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_subIndex == 3) {
|
||||||
|
final created = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return const AdminUserFormDialog(
|
||||||
|
adminMode: true,
|
||||||
|
withRelais: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
if (created == true) {
|
||||||
|
setState(() {
|
||||||
|
_adminRefreshTick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'La création est disponible pour les gestionnaires et administrateurs.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:p_tits_pas/models/m_dashbord/child_model.dart';
|
import 'package:p_tits_pas/models/m_dashbord/child_model.dart';
|
||||||
import 'package:p_tits_pas/services/bug_report_service.dart';
|
import 'package:p_tits_pas/services/bug_report_service.dart';
|
||||||
@@ -185,13 +186,11 @@ class AppFooter extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLegalNotices(BuildContext context) {
|
void _handleLegalNotices(BuildContext context) {
|
||||||
// Handle legal notices action
|
context.push('/legal');
|
||||||
Navigator.pushNamed(context, '/legal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePrivacyPolicy(BuildContext context) {
|
void _handlePrivacyPolicy(BuildContext context) {
|
||||||
// Handle privacy policy action
|
context.push('/privacy');
|
||||||
Navigator.pushNamed(context, '/privacy');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleContactSupport(BuildContext context) {
|
void _handleContactSupport(BuildContext context) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum CustomAppTextFieldStyle {
|
|||||||
|
|
||||||
class CustomAppTextField extends StatefulWidget {
|
class CustomAppTextField extends StatefulWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
|
final FocusNode? focusNode;
|
||||||
final String labelText;
|
final String labelText;
|
||||||
final String hintText;
|
final String hintText;
|
||||||
final double fieldWidth;
|
final double fieldWidth;
|
||||||
@@ -26,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
final double labelFontSize;
|
final double labelFontSize;
|
||||||
final double inputFontSize;
|
final double inputFontSize;
|
||||||
final bool showLabel;
|
final bool showLabel;
|
||||||
|
final Iterable<String>? autofillHints;
|
||||||
|
final TextInputAction? textInputAction;
|
||||||
|
final ValueChanged<String>? onFieldSubmitted;
|
||||||
|
|
||||||
const CustomAppTextField({
|
const CustomAppTextField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
this.focusNode,
|
||||||
required this.labelText,
|
required this.labelText,
|
||||||
this.showLabel = true,
|
this.showLabel = true,
|
||||||
this.hintText = '',
|
this.hintText = '',
|
||||||
@@ -46,6 +51,9 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
this.labelFontSize = 18.0,
|
this.labelFontSize = 18.0,
|
||||||
this.inputFontSize = 18.0,
|
this.inputFontSize = 18.0,
|
||||||
|
this.autofillHints,
|
||||||
|
this.textInputAction,
|
||||||
|
this.onFieldSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -68,7 +76,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const double fontHeightMultiplier = 1.2;
|
const double fontHeightMultiplier = 1.2;
|
||||||
const double internalVerticalPadding = 16.0;
|
const double internalVerticalPadding = 16.0;
|
||||||
final double dynamicFieldHeight = widget.fieldHeight;
|
final double dynamicFieldHeight = widget.fieldHeight;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -90,7 +98,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
width: widget.fieldWidth,
|
width: widget.fieldWidth,
|
||||||
height: dynamicFieldHeight,
|
height: dynamicFieldHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
@@ -99,40 +107,49 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
|
focusNode: widget.focusNode,
|
||||||
obscureText: widget.obscureText,
|
obscureText: widget.obscureText,
|
||||||
keyboardType: widget.keyboardType,
|
keyboardType: widget.keyboardType,
|
||||||
|
autofillHints: widget.autofillHints,
|
||||||
|
textInputAction: widget.textInputAction,
|
||||||
|
onFieldSubmitted: widget.onFieldSubmitted,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
style: GoogleFonts.merienda(
|
style: GoogleFonts.merienda(
|
||||||
fontSize: widget.inputFontSize,
|
fontSize: widget.inputFontSize,
|
||||||
color: widget.enabled ? Colors.black87 : Colors.grey
|
color: widget.enabled ? Colors.black87 : Colors.grey),
|
||||||
),
|
|
||||||
validator: widget.validator ??
|
validator: widget.validator ??
|
||||||
(value) {
|
(value) {
|
||||||
if (!widget.enabled || widget.readOnly) return null;
|
if (!widget.enabled || widget.readOnly) return null;
|
||||||
if (widget.isRequired && (value == null || value.isEmpty)) {
|
if (widget.isRequired &&
|
||||||
return 'Ce champ est obligatoire';
|
(value == null || value.isEmpty)) {
|
||||||
}
|
return 'Ce champ est obligatoire';
|
||||||
return null;
|
}
|
||||||
},
|
return null;
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: widget.hintText,
|
hintText: widget.hintText,
|
||||||
hintStyle: GoogleFonts.merienda(fontSize: widget.inputFontSize, color: Colors.black54.withOpacity(0.7)),
|
hintStyle: GoogleFonts.merienda(
|
||||||
|
fontSize: widget.inputFontSize,
|
||||||
|
color: Colors.black54.withOpacity(0.7)),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
suffixIcon: widget.suffixIcon != null
|
suffixIcon: widget.suffixIcon != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(right: 0.0),
|
padding: const EdgeInsets.only(right: 0.0),
|
||||||
child: Icon(widget.suffixIcon, color: Colors.black54, size: widget.inputFontSize * 1.1),
|
child: Icon(widget.suffixIcon,
|
||||||
|
color: Colors.black54,
|
||||||
|
size: widget.inputFontSize * 1.1),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,4 +158,4 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
|
|
||||||
|
/// Item d'onglet pour le bandeau (label + enabled).
|
||||||
|
class DashboardTabItem {
|
||||||
|
final String label;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const DashboardTabItem({
|
||||||
|
required this.label,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icône associée au rôle utilisateur (alignée sur le panneau admin).
|
||||||
|
IconData _iconForRole(String? role) {
|
||||||
|
if (role == null || role.isEmpty) return Icons.person_outline;
|
||||||
|
final r = role.toLowerCase();
|
||||||
|
if (r == 'super_admin') return Icons.verified_user_outlined;
|
||||||
|
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
|
||||||
|
if (r == 'gestionnaire') return Icons.assignment_ind_outlined;
|
||||||
|
if (r == 'parent') return Icons.supervisor_account_outlined;
|
||||||
|
if (r == 'assistante_maternelle') return Icons.face;
|
||||||
|
return Icons.person_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bandeau générique type Gitea : icône | onglets | capsule (Prénom Nom ▼) → menu (email, Profil, Paramètres, Déconnexion).
|
||||||
|
class DashboardBandeau extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
final Widget? leading;
|
||||||
|
final List<DashboardTabItem> tabItems;
|
||||||
|
final int selectedTabIndex;
|
||||||
|
final ValueChanged<int> onTabSelected;
|
||||||
|
final String userDisplayName;
|
||||||
|
final String? userEmail;
|
||||||
|
/// Rôle de l'utilisateur pour afficher l'icône correspondante (même que panneau admin).
|
||||||
|
final String? userRole;
|
||||||
|
final VoidCallback? onProfileTap;
|
||||||
|
final VoidCallback? onSettingsTap;
|
||||||
|
final VoidCallback? onLogout;
|
||||||
|
final bool showLogoutConfirmation;
|
||||||
|
final bool bottomBorder;
|
||||||
|
final double? preferredHeight;
|
||||||
|
|
||||||
|
const DashboardBandeau({
|
||||||
|
super.key,
|
||||||
|
this.leading,
|
||||||
|
required this.tabItems,
|
||||||
|
required this.selectedTabIndex,
|
||||||
|
required this.onTabSelected,
|
||||||
|
required this.userDisplayName,
|
||||||
|
this.userEmail,
|
||||||
|
this.userRole,
|
||||||
|
this.onProfileTap,
|
||||||
|
this.onSettingsTap,
|
||||||
|
this.onLogout,
|
||||||
|
this.showLogoutConfirmation = true,
|
||||||
|
this.bottomBorder = true,
|
||||||
|
this.preferredHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize =>
|
||||||
|
Size.fromHeight(preferredHeight ?? (kToolbarHeight + 10));
|
||||||
|
|
||||||
|
Widget _defaultLeading() {
|
||||||
|
return Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).appBarTheme.backgroundColor ?? Colors.white,
|
||||||
|
border: bottomBorder
|
||||||
|
? Border(bottom: BorderSide(color: Colors.grey.shade300))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
leading ?? _defaultLeading(),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < tabItems.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 24),
|
||||||
|
_buildNavItem(
|
||||||
|
context,
|
||||||
|
title: tabItems[i].label,
|
||||||
|
index: i,
|
||||||
|
enabled: tabItems[i].enabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
_buildUserCapsule(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavItem(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required int index,
|
||||||
|
bool enabled = true,
|
||||||
|
}) {
|
||||||
|
final isActive = index == selectedTabIndex;
|
||||||
|
return InkWell(
|
||||||
|
onTap: enabled ? () => onTabSelected(index) : null,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabled ? 1.0 : 0.5,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: isActive ? null : Border.all(color: Colors.black26),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive ? Colors.white : Colors.black,
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUserCapsule(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: PopupMenuButton<String?>(
|
||||||
|
offset: const Offset(0, 45),
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'profile':
|
||||||
|
onProfileTap?.call();
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
onSettingsTap?.call();
|
||||||
|
break;
|
||||||
|
case 'logout':
|
||||||
|
_handleLogout(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) {
|
||||||
|
final entries = <PopupMenuEntry<String?>>[];
|
||||||
|
if (userEmail != null && userEmail!.isNotEmpty) {
|
||||||
|
entries.add(
|
||||||
|
PopupMenuItem<String?>(
|
||||||
|
enabled: false,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.email_outlined, size: 16, color: Colors.grey.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
userEmail!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
entries.add(const PopupMenuDivider());
|
||||||
|
}
|
||||||
|
if (onProfileTap != null) {
|
||||||
|
entries.add(
|
||||||
|
const PopupMenuItem<String?>(
|
||||||
|
value: 'profile',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.person_outline, size: 20),
|
||||||
|
title: Text('Modification du profil'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onSettingsTap != null) {
|
||||||
|
entries.add(
|
||||||
|
const PopupMenuItem<String?>(
|
||||||
|
value: 'settings',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.settings_outlined, size: 20),
|
||||||
|
title: Text('Paramètres'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onLogout != null) {
|
||||||
|
if (entries.isNotEmpty) entries.add(const PopupMenuDivider());
|
||||||
|
entries.add(
|
||||||
|
const PopupMenuItem<String?>(
|
||||||
|
value: 'logout',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.logout, size: 20),
|
||||||
|
title: Text('Déconnexion'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(_iconForRole(userRole), size: 18, color: Colors.grey.shade700),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
userDisplayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey.shade700),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLogout(BuildContext context) {
|
||||||
|
if (showLogoutConfirmation) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Déconnexion'),
|
||||||
|
content: const Text(
|
||||||
|
'Êtes-vous sûr de vouloir vous déconnecter ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
onLogout?.call();
|
||||||
|
await AuthService.logout();
|
||||||
|
if (context.mounted) context.go('/login');
|
||||||
|
},
|
||||||
|
child: const Text('Déconnecter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onLogout?.call();
|
||||||
|
AuthService.logout().then((_) {
|
||||||
|
if (context.mounted) context.go('/login');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
||||||
final int selectedIndex;
|
|
||||||
final ValueChanged<int> onTabChange;
|
|
||||||
|
|
||||||
const DashboardAppBar({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isMobile = MediaQuery.of(context).size.width < 768;
|
|
||||||
return AppBar(
|
|
||||||
// backgroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
// Logo de la ville
|
|
||||||
// Container(
|
|
||||||
// height: 32,
|
|
||||||
// width: 32,
|
|
||||||
// decoration: BoxDecoration(
|
|
||||||
// color: Colors.white,
|
|
||||||
// borderRadius: BorderRadius.circular(8),
|
|
||||||
// ),
|
|
||||||
// child: const Icon(
|
|
||||||
// Icons.location_city,
|
|
||||||
// color: Color(0xFF9CC5C0),
|
|
||||||
// size: 20,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
SizedBox(width: MediaQuery.of(context).size.width * 0.19),
|
|
||||||
const Text(
|
|
||||||
"P'tit Pas",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFF9CC5C0),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
|
|
||||||
|
|
||||||
// Navigation principale
|
|
||||||
_buildNavItem(context, 'Mon tableau de bord', 0),
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
_buildNavItem(context, 'Trouver une nounou', 1),
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
_buildNavItem(context, 'Paramètres', 2),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: isMobile
|
|
||||||
? [_buildMobileMenu(context)]
|
|
||||||
: [
|
|
||||||
// Nom de l'utilisateur
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Jean Dupont',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bouton déconnexion
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => _handleLogout(context),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF9CC5C0),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Se déconnecter'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNavItem(BuildContext context, String title, int index) {
|
|
||||||
final bool isActive = index == selectedIndex;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onTabChange(index),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: isActive ? null : Border.all(color: Colors.black26),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isActive ? Colors.white : Colors.black,
|
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Widget _buildMobileMenu(BuildContext context) {
|
|
||||||
return PopupMenuButton<int>(
|
|
||||||
icon: const Icon(Icons.menu, color: Colors.white),
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 3) {
|
|
||||||
_handleLogout(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(value: 0, child: Text("Mon tableau de bord")),
|
|
||||||
const PopupMenuItem(value: 1, child: Text("Trouver une nounou")),
|
|
||||||
const PopupMenuItem(value: 2, child: Text("Paramètres")),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(value: 3, child: Text("Se déconnecter")),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleLogout(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Déconnexion'),
|
|
||||||
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
// TODO: Implémenter la logique de déconnexion
|
|
||||||
},
|
|
||||||
child: const Text('Déconnecter'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,26 +23,38 @@ class ImageButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return SizedBox(
|
||||||
cursor: SystemMouseCursors.click,
|
width: width,
|
||||||
child: GestureDetector(
|
height: height,
|
||||||
onTap: onPressed,
|
child: Semantics(
|
||||||
child: Container(
|
button: true,
|
||||||
width: width,
|
label: text,
|
||||||
height: height,
|
child: MouseRegion(
|
||||||
decoration: BoxDecoration(
|
cursor: SystemMouseCursors.click,
|
||||||
image: DecorationImage(
|
child: TextButton(
|
||||||
image: AssetImage(bg),
|
onPressed: onPressed,
|
||||||
fit: BoxFit.fill,
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
shape:
|
||||||
|
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||||
),
|
),
|
||||||
),
|
child: Ink(
|
||||||
child: Center(
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
image: DecorationImage(
|
||||||
text,
|
image: AssetImage(bg),
|
||||||
style: GoogleFonts.merienda(
|
fit: BoxFit.fill,
|
||||||
color: textColor,
|
),
|
||||||
fontSize: fontSize, // Utilisation du paramètre
|
),
|
||||||
fontWeight: FontWeight.bold,
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: fontSize, // Utilisation du paramètre
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -50,4 +62,4 @@ class ImageButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Créer l’issue #84 (correctifs modale MDP) via l’API Gitea
|
|
||||||
|
|
||||||
1. Définir un token valide :
|
|
||||||
`export GITEA_TOKEN="votre_token"`
|
|
||||||
ou créer `.gitea-token` à la racine du projet avec le token seul.
|
|
||||||
|
|
||||||
2. Créer l’issue :
|
|
||||||
```bash
|
|
||||||
cd /chemin/vers/PetitsPas
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @scripts/issue-84-payload.json \
|
|
||||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. En cas de succès (HTTP 201), la réponse JSON contient le numéro de l’issue créée.
|
|
||||||
|
|
||||||
Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`).
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Crée une issue Gitea via l'API.
|
|
||||||
# Usage: GITEA_TOKEN=xxx ./scripts/create-gitea-issue.sh
|
|
||||||
# Ou: mettre le token dans .gitea-token à la racine du projet.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
|
|
||||||
REPO="jmartin/petitspas"
|
|
||||||
|
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
if [ -f .gitea-token ]; then
|
|
||||||
GITEA_TOKEN=$(cat .gitea-token)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TITLE="$1"
|
|
||||||
BODY="$2"
|
|
||||||
if [ -z "$TITLE" ]; then
|
|
||||||
echo "Usage: $0 \"Titre de l'issue\" \"Corps (optionnel)\""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build JSON (escape body for JSON)
|
|
||||||
BODY_ESC=$(echo "$BODY" | jq -Rs . 2>/dev/null || echo "null")
|
|
||||||
if [ "$BODY_ESC" = "null" ] || [ -z "$BODY" ]; then
|
|
||||||
PAYLOAD=$(jq -n --arg t "$TITLE" '{title: $t}')
|
|
||||||
else
|
|
||||||
PAYLOAD=$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b}')
|
|
||||||
fi
|
|
||||||
|
|
||||||
RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$BASE_URL/repos/$REPO/issues")
|
|
||||||
HTTP_CODE=$(echo "$RESP" | tail -1)
|
|
||||||
BODY_RESP=$(echo "$RESP" | sed '$d')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "201" ]; then
|
|
||||||
ISSUE_NUM=$(echo "$BODY_RESP" | jq -r .number)
|
|
||||||
echo "Issue #$ISSUE_NUM créée."
|
|
||||||
echo "$BODY_RESP" | jq .
|
|
||||||
else
|
|
||||||
echo "Erreur HTTP $HTTP_CODE: $BODY_RESP"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Poste un commentaire sur une issue Gitea puis la ferme.
|
|
||||||
# Usage: GITEA_TOKEN=xxx ./scripts/gitea-close-issue-with-comment.sh <numéro> "Commentaire"
|
|
||||||
# Ou: mettre le token dans .gitea-token à la racine du projet.
|
|
||||||
# Exemple: ./scripts/gitea-close-issue-with-comment.sh 15 "Livré : panneau Paramètres opérationnel."
|
|
||||||
|
|
||||||
set -e
|
|
||||||
ISSUE="${1:?Usage: $0 <numéro_issue> \"Commentaire\"}"
|
|
||||||
COMMENT="${2:?Usage: $0 <numéro_issue> \"Commentaire\"}"
|
|
||||||
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
|
|
||||||
REPO="jmartin/petitspas"
|
|
||||||
|
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
if [ -f .gitea-token ]; then
|
|
||||||
GITEA_TOKEN=$(cat .gitea-token)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1) Poster le commentaire
|
|
||||||
echo "Ajout du commentaire sur l'issue #$ISSUE..."
|
|
||||||
# Échapper pour JSON (guillemets et backslash)
|
|
||||||
COMMENT_ESC=$(printf '%s' "$COMMENT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g')
|
|
||||||
PAYLOAD="{\"body\":\"$COMMENT_ESC\"}"
|
|
||||||
RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$BASE_URL/repos/$REPO/issues/$ISSUE/comments")
|
|
||||||
HTTP_CODE=$(echo "$RESP" | tail -1)
|
|
||||||
BODY=$(echo "$RESP" | sed '$d')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" != "201" ]; then
|
|
||||||
echo "Erreur HTTP $HTTP_CODE lors du commentaire: $BODY"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Commentaire ajouté."
|
|
||||||
|
|
||||||
# 2) Fermer l'issue
|
|
||||||
echo "Fermeture de l'issue #$ISSUE..."
|
|
||||||
RESP2=$(curl -s -w "\n%{http_code}" -X PATCH \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"state":"closed"}' \
|
|
||||||
"$BASE_URL/repos/$REPO/issues/$ISSUE")
|
|
||||||
HTTP_CODE2=$(echo "$RESP2" | tail -1)
|
|
||||||
BODY2=$(echo "$RESP2" | sed '$d')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE2" = "200" ] || [ "$HTTP_CODE2" = "201" ]; then
|
|
||||||
echo "Issue #$ISSUE fermée."
|
|
||||||
else
|
|
||||||
echo "Erreur HTTP $HTTP_CODE2: $BODY2"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.
|
|
||||||
|
|
||||||
**Périmètre :**
|
|
||||||
- Ajustements visuels / UX de la modale (ChangePasswordDialog)
|
|
||||||
- Cohérence charte graphique, espacements, lisibilité
|
|
||||||
- Comportement (validation, messages d'erreur, fermeture)
|
|
||||||
- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages
|
|
||||||
|
|
||||||
**Tâches :**
|
|
||||||
- [ ] Revoir le design de la modale (relief, bordures, couleurs)
|
|
||||||
- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations
|
|
||||||
- [ ] Ajuster les textes et messages d'erreur
|
|
||||||
- [ ] Tester sur mobile et desktop
|
|
||||||
- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"title": "[Frontend] Bug – Correctifs modale Changement MDP (première connexion admin)", "body": "Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.\n\n**Périmètre :**\n- Ajustements visuels / UX de la modale (ChangePasswordDialog)\n- Cohérence charte graphique, espacements, lisibilité\n- Comportement (validation, messages d'erreur, fermeture)\n- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages\n\n**Tâches :**\n- [ ] Revoir le design de la modale (relief, bordures, couleurs)\n- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations\n- [ ] Ajuster les textes et messages d'erreur\n- [ ] Tester sur mobile et desktop\n- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin\n"}
|
|
||||||
Reference in New Issue
Block a user