feat(#140): dashboard admin ch.6 — fiche parent, enfants, affiliation
Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi. Front: modale parent éditable, onglet Enfants, fiche enfant, UserService. Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138. Hors scope: fiche AM (#131), création admin (#129). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
df776d8200
commit
1dddc67933
@ -83,7 +83,12 @@ export class EnfantsController {
|
|||||||
return this.enfantsService.findOne(id, currentUser);
|
return this.enfantsService.findOne(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
|
@Roles(
|
||||||
|
RoleType.PARENT,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
|||||||
@ -78,10 +78,10 @@ export class EnfantsService {
|
|||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants
|
// Liste des enfants (admin/gestionnaire)
|
||||||
async findAll(): Promise<Children[]> {
|
async findAll(): Promise<Children[]> {
|
||||||
return this.childrenRepository.find({
|
return this.childrenRepository.find({
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
|
||||||
|
export class UpdateParentFicheAdminDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Dupont' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
nom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Marie' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '+33612345678' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
telephone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '10 rue de la Paix' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Paris' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '75001' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: StatutUtilisateurType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatutUtilisateurType)
|
||||||
|
statut?: StatutUtilisateurType;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@ -16,6 +17,7 @@ import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
|||||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
@ -87,7 +89,7 @@ export class ParentsController {
|
|||||||
return this.parentsService.findAll();
|
return this.parentsService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
||||||
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
||||||
@ -105,6 +107,45 @@ export class ParentsController {
|
|||||||
return this.parentsService.create(dto);
|
return this.parentsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@Patch(':id/fiche')
|
||||||
|
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
|
||||||
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
|
@ApiBody({ type: UpdateParentFicheAdminDto })
|
||||||
|
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
|
||||||
|
updateFicheAdmin(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateParentFicheAdminDto,
|
||||||
|
): Promise<Parents> {
|
||||||
|
return this.parentsService.updateFicheAdmin(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@Post(':id/enfants/:enfantId')
|
||||||
|
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||||
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
|
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||||
|
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||||
|
attachEnfant(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('enfantId') enfantId: string,
|
||||||
|
): Promise<Parents> {
|
||||||
|
return this.parentsService.attachEnfant(id, enfantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@Delete(':id/enfants/:enfantId')
|
||||||
|
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
|
||||||
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
|
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||||
|
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||||
|
detachEnfant(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('enfantId') enfantId: string,
|
||||||
|
): Promise<Parents> {
|
||||||
|
return this.parentsService.detachEnfant(id, enfantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiBody({ type: UpdateParentsDto })
|
@ApiBody({ type: UpdateParentsDto })
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@ -17,7 +17,9 @@ import {
|
|||||||
DossierFamilleParentDto,
|
DossierFamilleParentDto,
|
||||||
DossierFamilleEnfantDto,
|
DossierFamilleEnfantDto,
|
||||||
} from './dto/dossier-famille-complet.dto';
|
} from './dto/dossier-famille-complet.dto';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@ -28,6 +30,8 @@ export class ParentsService {
|
|||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@InjectRepository(DossierFamille)
|
@InjectRepository(DossierFamille)
|
||||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’un parent
|
// Création d’un parent
|
||||||
@ -62,7 +66,7 @@ export class ParentsService {
|
|||||||
// Liste des parents
|
// Liste des parents
|
||||||
async findAll(): Promise<Parents[]> {
|
async findAll(): Promise<Parents[]> {
|
||||||
return this.parentsRepository.find({
|
return this.parentsRepository.find({
|
||||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ export class ParentsService {
|
|||||||
async findOne(user_id: string): Promise<Parents> {
|
async findOne(user_id: string): Promise<Parents> {
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id },
|
where: { user_id },
|
||||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
return parent;
|
return parent;
|
||||||
@ -82,6 +86,79 @@ export class ParentsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
|
||||||
|
*/
|
||||||
|
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
const user = parent.user;
|
||||||
|
|
||||||
|
if (dto.email && dto.email !== user.email) {
|
||||||
|
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
throw new ConflictException('Cet email est déjà utilisé');
|
||||||
|
}
|
||||||
|
user.email = dto.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||||
|
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||||
|
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||||||
|
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||||||
|
if (dto.ville !== undefined) user.ville = dto.ville;
|
||||||
|
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||||||
|
if (dto.statut !== undefined) user.statut = dto.statut;
|
||||||
|
|
||||||
|
await this.usersRepository.save(user);
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const existing = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: parentUserId, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
||||||
|
if (!child) {
|
||||||
|
throw new NotFoundException('Enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.save(
|
||||||
|
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
||||||
|
);
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||||
|
*/
|
||||||
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: parentUserId, enfantId },
|
||||||
|
});
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
||||||
|
if (totalLinks <= 1) {
|
||||||
|
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des familles en attente (une entrée par famille).
|
* Liste des familles en attente (une entrée par famille).
|
||||||
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||||
|
|||||||
@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
|||||||
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
||||||
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
||||||
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
||||||
|
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
|
||||||
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
||||||
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
|
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
|
|||||||
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
||||||
possible :
|
possible :
|
||||||
- `CHARTE_GRAPHIQUE.md`
|
- `CHARTE_GRAPHIQUE.md`
|
||||||
- `EVOLUTIONS_CDC.md`
|
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
|
||||||
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
||||||
- `SuperNounou_SSS-001.md`
|
- `SuperNounou_SSS-001.md`
|
||||||
|
|
||||||
@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin
|
|||||||
|
|
||||||
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
||||||
|
|
||||||
Dernière mise à jour : Novembre 2025
|
Dernière mise à jour : Juin 2026
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# 📋 Décisions Projet - P'titsPas
|
# 📋 Décisions Projet - P'titsPas
|
||||||
|
|
||||||
**Version** : 1.1
|
**Version** : 1.2
|
||||||
**Date** : 9 Février 2026
|
**Date** : 16 Juin 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -122,6 +122,27 @@ ptitspas-app/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 5bis. Familles recomposées — contournement v1.0.0
|
||||||
|
|
||||||
|
**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0
|
||||||
|
|
||||||
|
**Contexte** :
|
||||||
|
- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants).
|
||||||
|
- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes).
|
||||||
|
|
||||||
|
**Décision v1.0.0** :
|
||||||
|
- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire).
|
||||||
|
- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes.
|
||||||
|
- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure.
|
||||||
|
|
||||||
|
**Évolution post-1.0.0** :
|
||||||
|
- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`.
|
||||||
|
- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsable–enfant.
|
||||||
|
|
||||||
|
**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 6. Genre enfant obligatoire (H/F)
|
### 6. Genre enfant obligatoire (H/F)
|
||||||
|
|
||||||
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
||||||
@ -562,6 +583,7 @@ docs/
|
|||||||
| 14 | Migration données | ❌ Rejeté | N/A |
|
| 14 | Migration données | ❌ Rejeté | N/A |
|
||||||
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
||||||
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
||||||
|
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -571,10 +593,12 @@ docs/
|
|||||||
|------|---------|---------------|
|
|------|---------|---------------|
|
||||||
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
||||||
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
||||||
|
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
|
||||||
|
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 9 Février 2026
|
**Dernière mise à jour** : 16 Juin 2026
|
||||||
**Version** : 1.1
|
**Version** : 1.2
|
||||||
**Statut** : ✅ Document validé
|
**Statut** : ✅ Document validé
|
||||||
|
|
||||||
|
|||||||
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal file
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
# Évolution — Modèle famille, responsables légaux et dossiers
|
||||||
|
|
||||||
|
**Version** : 1.1
|
||||||
|
**Date** : 16 juin 2026
|
||||||
|
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
||||||
|
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objet de ce document
|
||||||
|
|
||||||
|
Ce document **trace les réflexions** menées en 2026 sur :
|
||||||
|
|
||||||
|
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
|
||||||
|
- les **contournements** acceptés pour la release **1.0.0** ;
|
||||||
|
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie).
|
||||||
|
|
||||||
|
Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modèle actuel (v1.0.0) — rappel
|
||||||
|
|
||||||
|
| Concept | Implémentation |
|
||||||
|
|--------|----------------|
|
||||||
|
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
|
||||||
|
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
|
||||||
|
| **Enfant** | Entité `enfants` |
|
||||||
|
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
|
||||||
|
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
|
||||||
|
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
|
||||||
|
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
|
||||||
|
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
|
||||||
|
|
||||||
|
Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
|
||||||
|
|
||||||
|
### 3.1 Exemple type (illustration)
|
||||||
|
|
||||||
|
```
|
||||||
|
Année 1 : Responsable M + co-responsable A → enfant α
|
||||||
|
Année 2 : Responsable M + co-responsable B → enfant β
|
||||||
|
```
|
||||||
|
|
||||||
|
Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α.
|
||||||
|
|
||||||
|
### 3.2 Autres cas couverts par la même limitation
|
||||||
|
|
||||||
|
Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** :
|
||||||
|
|
||||||
|
| Configuration | Même règle |
|
||||||
|
|---------------|------------|
|
||||||
|
| Couple **HH** ou **FF** | Oui |
|
||||||
|
| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui |
|
||||||
|
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
|
||||||
|
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
|
||||||
|
|
||||||
|
### 3.3 Pourquoi le modèle casse
|
||||||
|
|
||||||
|
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
|
||||||
|
2. **Un seul co-parent** par fiche `parents`.
|
||||||
|
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
|
||||||
|
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Décision v1.0.0 — contournement opérationnel
|
||||||
|
|
||||||
|
### 4.1 Principe
|
||||||
|
|
||||||
|
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
|
||||||
|
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
|
||||||
|
|
||||||
|
### 4.2 Contournement accepté pour la 1.0.0
|
||||||
|
|
||||||
|
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
|
||||||
|
|
||||||
|
| Dossier | Compte | Co-responsable | Enfant |
|
||||||
|
|---------|--------|----------------|--------|
|
||||||
|
| 1 | `m.personne@…` | A | α |
|
||||||
|
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
|
||||||
|
|
||||||
|
**Conséquences assumées :**
|
||||||
|
|
||||||
|
- Une **même personne physique** peut avoir **deux identités** dans l’app.
|
||||||
|
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
|
||||||
|
- Procédure interne gestionnaire recommandée (note « même personne physique »).
|
||||||
|
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
|
||||||
|
|
||||||
|
### 4.3 Nuance — inscription publique
|
||||||
|
|
||||||
|
Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
|
||||||
|
|
||||||
|
Le **2ᵉ dossier** passe donc surtout par :
|
||||||
|
|
||||||
|
- **création / composition par le gestionnaire** (tickets admin #129+), ou
|
||||||
|
- **2ᵉ email** dès le départ pour la même personne physique.
|
||||||
|
|
||||||
|
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
|
||||||
|
|
||||||
|
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
|
||||||
|
|
||||||
|
> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité.
|
||||||
|
|
||||||
|
**Exemple M / A / B / α / β :**
|
||||||
|
|
||||||
|
| Compte | Enfants visibles / rattachés |
|
||||||
|
|--------|------------------------------|
|
||||||
|
| **M** (un seul compte, un email) | α **et** β |
|
||||||
|
| **A** | α uniquement |
|
||||||
|
| **B** | β uniquement |
|
||||||
|
|
||||||
|
```
|
||||||
|
α ─── M ─── β
|
||||||
|
│ │
|
||||||
|
A B
|
||||||
|
```
|
||||||
|
|
||||||
|
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M).
|
||||||
|
- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
|
||||||
|
- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
|
||||||
|
|
||||||
|
**Conséquences techniques (post-1.0.0) :**
|
||||||
|
|
||||||
|
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
|
||||||
|
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
|
||||||
|
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
|
||||||
|
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
|
||||||
|
|
||||||
|
*Détail : § 7.5.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Terminologie — « parent » aujourd’hui, qualification demain
|
||||||
|
|
||||||
|
### 5.1 v1.0.0
|
||||||
|
|
||||||
|
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
|
||||||
|
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
|
||||||
|
|
||||||
|
### 5.2 Évolution post-1.0.0 (piste)
|
||||||
|
|
||||||
|
Séparer deux niveaux :
|
||||||
|
|
||||||
|
| Niveau | Évolution |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
|
||||||
|
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
|
||||||
|
|
||||||
|
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
|
||||||
|
|
||||||
|
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
|
||||||
|
|
||||||
|
**UI** :
|
||||||
|
|
||||||
|
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
|
||||||
|
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Évolutions UI / fonctionnelles liées (backlog)
|
||||||
|
|
||||||
|
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3).
|
||||||
|
|
||||||
|
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129).
|
||||||
|
|
||||||
|
### 6.1 Fiche parent (#131)
|
||||||
|
|
||||||
|
- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice).
|
||||||
|
- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule.
|
||||||
|
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
|
||||||
|
- Bas de modale :
|
||||||
|
- `Nombre d'enfants : N` (lecture seule) ;
|
||||||
|
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
|
||||||
|
|
||||||
|
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
|
||||||
|
|
||||||
|
- **Vérité métier** : `enfants_parents`.
|
||||||
|
- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
|
||||||
|
- ne pas supprimer l’enfant pour retirer un lien ;
|
||||||
|
- au moins un responsable par enfant ;
|
||||||
|
- prudence sur fusion « famille » et co-parents.
|
||||||
|
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
|
||||||
|
|
||||||
|
### 6.3 Création admin sans numéro (tickets #129+)
|
||||||
|
|
||||||
|
Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
|
||||||
|
|
||||||
|
### 7.1 Principe cible
|
||||||
|
|
||||||
|
| Aujourd’hui | Cible |
|
||||||
|
|-------------|--------|
|
||||||
|
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
|
||||||
|
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
|
||||||
|
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
|
||||||
|
|
||||||
|
### 7.2 Exemple cible (cas M / A / B) — deux approches
|
||||||
|
|
||||||
|
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
|
||||||
|
|
||||||
|
```
|
||||||
|
Unité 1 : numero 2026-000021 — M, A, α
|
||||||
|
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
|
||||||
|
```
|
||||||
|
|
||||||
|
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
|
||||||
|
|
||||||
|
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
|
||||||
|
|
||||||
|
```
|
||||||
|
Compte M → enfants α, β
|
||||||
|
Compte A → enfant α
|
||||||
|
Compte B → enfant β
|
||||||
|
(liens enfants_parents ; pas de fusion A↔B)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Un seul compte pour M** avec **les deux enfants**.
|
||||||
|
- **A** et **B** isolés sur **leur** enfant respectif.
|
||||||
|
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
|
||||||
|
|
||||||
|
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité.
|
||||||
|
|
||||||
|
### 7.5 Parcours gestionnaire — création « famille complexe »
|
||||||
|
|
||||||
|
#### 7.5.1 Objectif
|
||||||
|
|
||||||
|
Permettre au **gestionnaire** de monter un dossier où :
|
||||||
|
|
||||||
|
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
|
||||||
|
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
|
||||||
|
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
|
||||||
|
|
||||||
|
#### 7.5.2 Règles produit
|
||||||
|
|
||||||
|
| Règle | Détail |
|
||||||
|
|-------|--------|
|
||||||
|
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
|
||||||
|
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
|
||||||
|
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
|
||||||
|
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
|
||||||
|
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
|
||||||
|
|
||||||
|
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
|
||||||
|
|
||||||
|
1. **Créer ou identifier** le responsable principal (ex. M).
|
||||||
|
2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts.
|
||||||
|
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
|
||||||
|
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
|
||||||
|
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés.
|
||||||
|
|
||||||
|
#### 7.5.4 Impacts techniques majeurs
|
||||||
|
|
||||||
|
| Domaine | Changement |
|
||||||
|
|---------|------------|
|
||||||
|
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant |
|
||||||
|
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
|
||||||
|
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
|
||||||
|
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
|
||||||
|
| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
|
||||||
|
|
||||||
|
#### 7.5.5 Lien avec v1.0.0
|
||||||
|
|
||||||
|
| Phase | Comportement |
|
||||||
|
|-------|--------------|
|
||||||
|
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) |
|
||||||
|
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
|
||||||
|
|
||||||
|
### 7.3 Impacts workflows
|
||||||
|
|
||||||
|
| Flux | Adaptation future |
|
||||||
|
|------|-------------------|
|
||||||
|
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
|
||||||
|
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
|
||||||
|
| Liste « à valider » | Par soumission ou par enfant |
|
||||||
|
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
|
||||||
|
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
|
||||||
|
|
||||||
|
### 7.4 Migration
|
||||||
|
|
||||||
|
- **Court terme** : contournement § 4 + tickets admin.
|
||||||
|
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
|
||||||
|
- **Long terme** : workflows branchés sur l’unité.
|
||||||
|
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tickets Gitea associés
|
||||||
|
|
||||||
|
| Ticket | Sujet |
|
||||||
|
|--------|--------|
|
||||||
|
| #110 | Refus dossier (token reprise) — livré |
|
||||||
|
| #112 | Reprise après refus — livré |
|
||||||
|
| #115 / #116 | Rattachement parent — backend / front |
|
||||||
|
| #129+ | Création dossier admin (parent / AM) |
|
||||||
|
| #131 | Fiche parent éditable (dashboard) |
|
||||||
|
| #137 | Onglet Enfants — liste globale |
|
||||||
|
| #138 | Fiche enfant + liste dans fiche parent |
|
||||||
|
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
|
||||||
|
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
|
||||||
|
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
|
||||||
|
|
||||||
|
> **Familles recomposées ou responsabilités multiples**
|
||||||
|
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
|
||||||
|
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
|
||||||
|
> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
|
||||||
|
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Historique des mises à jour
|
||||||
|
|
||||||
|
| Date | Auteur | Modification |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
|
||||||
|
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
|
||||||
|
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
||||||
|
|
||||||
|
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
|
||||||
|
|
||||||
## 1. Gestion des Enfants
|
## 1. Gestion des Enfants
|
||||||
|
|
||||||
### Modifications à apporter dans la section "Création de compte parent"
|
### Modifications à apporter dans la section "Création de compte parent"
|
||||||
@ -275,4 +277,29 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
|||||||
- Évolution du modèle de données vers un RBAC intra-RPE.
|
- Évolution du modèle de données vers un RBAC intra-RPE.
|
||||||
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
- 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.
|
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||||
- Clarification des workflows décisionnels dans l'application.
|
- Clarification des workflows décisionnels dans l'application.
|
||||||
|
|
||||||
|
## 9. Modèle famille, numéro de dossier et responsables légaux
|
||||||
|
|
||||||
|
### 9.1 Situation actuelle (v1.0.0)
|
||||||
|
|
||||||
|
- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise).
|
||||||
|
- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`.
|
||||||
|
- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions.
|
||||||
|
|
||||||
|
### 9.2 Limitation v1.0.0 — familles recomposées
|
||||||
|
|
||||||
|
Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne.
|
||||||
|
|
||||||
|
**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc.
|
||||||
|
|
||||||
|
### 9.3 Évolutions post-1.0.0 (pistes)
|
||||||
|
|
||||||
|
| Sujet | Piste |
|
||||||
|
|-------|--------|
|
||||||
|
| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne |
|
||||||
|
| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) |
|
||||||
|
| **Qualification** | Combobox « lien responsable–enfant » (tuteur, GP, parent…) sur `enfants_parents` |
|
||||||
|
| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche |
|
||||||
|
|
||||||
|
**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
|
||||||
106
frontend/lib/models/enfant_admin_model.dart
Normal file
106
frontend/lib/models/enfant_admin_model.dart
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
|
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
|
||||||
|
class EnfantAdminModel {
|
||||||
|
final String id;
|
||||||
|
final String? firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String? gender;
|
||||||
|
final String? birthDate;
|
||||||
|
final String? dueDate;
|
||||||
|
final String status;
|
||||||
|
final String? photoUrl;
|
||||||
|
final bool consentPhoto;
|
||||||
|
final bool isMultiple;
|
||||||
|
final List<EnfantParentLink> parentLinks;
|
||||||
|
|
||||||
|
EnfantAdminModel({
|
||||||
|
required this.id,
|
||||||
|
this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
this.gender,
|
||||||
|
this.birthDate,
|
||||||
|
this.dueDate,
|
||||||
|
required this.status,
|
||||||
|
this.photoUrl,
|
||||||
|
this.consentPhoto = false,
|
||||||
|
this.isMultiple = false,
|
||||||
|
this.parentLinks = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName {
|
||||||
|
final fn = (firstName ?? '').trim();
|
||||||
|
final ln = (lastName ?? '').trim();
|
||||||
|
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
|
||||||
|
if (ln.isEmpty) return fn;
|
||||||
|
return '$fn $ln';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final linksRaw = json['parentLinks'] as List?;
|
||||||
|
final links = <EnfantParentLink>[];
|
||||||
|
if (linksRaw != null) {
|
||||||
|
for (final item in linksRaw) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
links.add(EnfantParentLink.fromJson(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnfantAdminModel(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
firstName: json['first_name'] as String?,
|
||||||
|
lastName: json['last_name'] as String?,
|
||||||
|
gender: json['gender'] as String?,
|
||||||
|
birthDate: _dateString(json['birth_date']),
|
||||||
|
dueDate: _dateString(json['due_date']),
|
||||||
|
status: (json['status'] ?? '').toString(),
|
||||||
|
photoUrl: json['photo_url'] as String?,
|
||||||
|
consentPhoto: json['consent_photo'] == true,
|
||||||
|
isMultiple: json['is_multiple'] == true,
|
||||||
|
parentLinks: links,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toUpdateJson() {
|
||||||
|
return {
|
||||||
|
if (firstName != null) 'first_name': firstName,
|
||||||
|
if (lastName != null) 'last_name': lastName,
|
||||||
|
if (gender != null && gender!.isNotEmpty) 'gender': gender,
|
||||||
|
'status': status,
|
||||||
|
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
||||||
|
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
||||||
|
'consent_photo': consentPhoto,
|
||||||
|
'is_multiple': isMultiple,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _dateString(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.split('T').first;
|
||||||
|
return v.toString().split('T').first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EnfantParentLink {
|
||||||
|
final String parentId;
|
||||||
|
final String? parentName;
|
||||||
|
|
||||||
|
EnfantParentLink({required this.parentId, this.parentName});
|
||||||
|
|
||||||
|
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parent = json['parent'];
|
||||||
|
String? name;
|
||||||
|
if (parent is Map<String, dynamic>) {
|
||||||
|
final user = parent['user'];
|
||||||
|
if (user is Map<String, dynamic>) {
|
||||||
|
final u = AppUser.fromJson(user);
|
||||||
|
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EnfantParentLink(
|
||||||
|
parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(),
|
||||||
|
parentName: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/lib/models/parent_child_summary.dart
Normal file
31
frontend/lib/models/parent_child_summary.dart
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
|
||||||
|
class ParentChildSummary {
|
||||||
|
final String id;
|
||||||
|
final String? firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
ParentChildSummary({
|
||||||
|
required this.id,
|
||||||
|
this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName {
|
||||||
|
final fn = (firstName ?? '').trim();
|
||||||
|
final ln = (lastName ?? '').trim();
|
||||||
|
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
|
||||||
|
if (ln.isEmpty) return fn;
|
||||||
|
return '$fn $ln';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ParentChildSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ParentChildSummary(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
firstName: json['first_name'] as String?,
|
||||||
|
lastName: json['last_name'] as String?,
|
||||||
|
status: (json['status'] ?? '').toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,40 @@
|
|||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
class ParentModel {
|
class ParentModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
final int childrenCount;
|
final int childrenCount;
|
||||||
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
ParentModel({required this.user, this.childrenCount = 0});
|
ParentModel({
|
||||||
|
required this.user,
|
||||||
|
this.childrenCount = 0,
|
||||||
|
this.children = const [],
|
||||||
|
});
|
||||||
|
|
||||||
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
||||||
final userJson = json['user'] ?? json;
|
final userJson = Map<String, dynamic>.from(json['user'] ?? json);
|
||||||
|
if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) {
|
||||||
|
userJson['numero_dossier'] = json['numero_dossier'];
|
||||||
|
}
|
||||||
final user = AppUser.fromJson(userJson);
|
final user = AppUser.fromJson(userJson);
|
||||||
final children = json['parentChildren'] as List?;
|
|
||||||
|
final children = <ParentChildSummary>[];
|
||||||
|
final links = json['parentChildren'] as List?;
|
||||||
|
if (links != null) {
|
||||||
|
for (final link in links) {
|
||||||
|
if (link is Map<String, dynamic> && link['child'] is Map<String, dynamic>) {
|
||||||
|
children.add(
|
||||||
|
ParentChildSummary.fromJson(link['child'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ParentModel(
|
return ParentModel(
|
||||||
user: user,
|
user: user,
|
||||||
childrenCount: children?.length ?? 0,
|
childrenCount: children.isNotEmpty ? children.length : (links?.length ?? 0),
|
||||||
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ 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 enfants = '/enfants';
|
||||||
static const String relais = '/relais';
|
static const String relais = '/relais';
|
||||||
static const String dossiers = '/dossiers';
|
static const String dossiers = '/dossiers';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.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/models/pending_family.dart';
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
@ -369,6 +370,123 @@ class UserService {
|
|||||||
return data.map((e) => ParentModel.fromJson(e)).toList();
|
return data.map((e) => ParentModel.fromJson(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> getParent(String userId) async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent');
|
||||||
|
}
|
||||||
|
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> updateParentFiche({
|
||||||
|
required String parentUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent'));
|
||||||
|
}
|
||||||
|
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> attachEnfantToParent({
|
||||||
|
required String parentUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||||
|
}
|
||||||
|
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> detachEnfantFromParent({
|
||||||
|
required String parentUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
|
||||||
|
}
|
||||||
|
if (response.body.isEmpty) {
|
||||||
|
return getParent(parentUserId);
|
||||||
|
}
|
||||||
|
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<EnfantAdminModel>> getEnfants() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants'));
|
||||||
|
}
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(EnfantAdminModel.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel> getEnfant(String enfantId) async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||||
|
}
|
||||||
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
|
required String enfantId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||||
|
}
|
||||||
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _extractErrorMessage(String body, String fallback) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
return message.join(' - ');
|
||||||
|
}
|
||||||
|
return _toStr(message) ?? fallback;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>>
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
getAssistantesMaternelles() async {
|
getAssistantesMaternelles() async {
|
||||||
|
|||||||
289
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal file
289
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
|
/// Fiche enfant consultation / édition (ticket #138).
|
||||||
|
class AdminChildDetailModal extends StatefulWidget {
|
||||||
|
final EnfantAdminModel enfant;
|
||||||
|
final VoidCallback? onSaved;
|
||||||
|
|
||||||
|
const AdminChildDetailModal({
|
||||||
|
super.key,
|
||||||
|
required this.enfant,
|
||||||
|
this.onSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _birthCtrl;
|
||||||
|
late final TextEditingController _dueCtrl;
|
||||||
|
late String _status;
|
||||||
|
late String _gender;
|
||||||
|
late bool _consentPhoto;
|
||||||
|
late bool _isMultiple;
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
static const _statuses = ['a_naitre', 'actif', 'scolarise'];
|
||||||
|
static const _genders = ['H', 'F', 'Autre'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.enfant;
|
||||||
|
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
|
||||||
|
_nomCtrl = TextEditingController(text: e.lastName ?? '');
|
||||||
|
_birthCtrl = TextEditingController(text: e.birthDate ?? '');
|
||||||
|
_dueCtrl = TextEditingController(text: e.dueDate ?? '');
|
||||||
|
_status = _statuses.contains(e.status) ? e.status : 'actif';
|
||||||
|
_gender = _genders.contains(e.gender) ? e.gender! : 'H';
|
||||||
|
_consentPhoto = e.consentPhoto;
|
||||||
|
_isMultiple = e.isMultiple;
|
||||||
|
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
||||||
|
c.addListener(_markDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markDirty() {
|
||||||
|
if (!_dirty) setState(() => _dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateEnfant(
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
body: {
|
||||||
|
'first_name': _prenomCtrl.text.trim(),
|
||||||
|
'last_name': _nomCtrl.text.trim(),
|
||||||
|
'status': _status,
|
||||||
|
'gender': _gender,
|
||||||
|
if (_birthCtrl.text.trim().isNotEmpty) 'birth_date': _birthCtrl.text.trim(),
|
||||||
|
if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(),
|
||||||
|
'consent_photo': _consentPhoto,
|
||||||
|
'is_multiple': _isMultiple,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Fiche enfant enregistrée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final parents = widget.enfant.parentLinks
|
||||||
|
.map((l) => l.parentName ?? l.parentId)
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.enfant.fullName,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (parents.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())),
|
||||||
|
_rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())),
|
||||||
|
_rowDropdown(
|
||||||
|
'Statut',
|
||||||
|
_status,
|
||||||
|
_statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(),
|
||||||
|
(v) => setState(() {
|
||||||
|
_status = v;
|
||||||
|
_dirty = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_rowDropdown(
|
||||||
|
'Genre',
|
||||||
|
_gender,
|
||||||
|
_genders.map((g) => MapEntry(g, g)).toList(),
|
||||||
|
(v) => setState(() {
|
||||||
|
_gender = v;
|
||||||
|
_dirty = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_rowField(
|
||||||
|
'Date naissance',
|
||||||
|
TextField(
|
||||||
|
controller: _birthCtrl,
|
||||||
|
decoration: _decoration(hint: 'AAAA-MM-JJ'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_rowField(
|
||||||
|
'Date prévue',
|
||||||
|
TextField(
|
||||||
|
controller: _dueCtrl,
|
||||||
|
decoration: _decoration(hint: 'AAAA-MM-JJ'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Consentement photo'),
|
||||||
|
value: _consentPhoto,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_consentPhoto = v;
|
||||||
|
_dirty = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Naissance multiple'),
|
||||||
|
value: _isMultiple,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_isMultiple = v;
|
||||||
|
_dirty = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: !_dirty || _saving ? null : _save,
|
||||||
|
icon: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: const Text('Sauvegarder'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _decoration({String? hint}) {
|
||||||
|
return InputDecoration(isDense: true, border: const OutlineInputBorder(), hintText: hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rowField(String label, Widget field) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: field),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rowDropdown(
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
List<MapEntry<String, String>> items,
|
||||||
|
ValueChanged<String> onChanged,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: items.any((e) => e.key == value) ? value : items.first.key,
|
||||||
|
decoration: _decoration(),
|
||||||
|
items: items
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) onChanged(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'a_naitre':
|
||||||
|
return 'À naître';
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'scolarise':
|
||||||
|
return 'Scolarisé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
477
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal file
477
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
|
|
||||||
|
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
|
||||||
|
class AdminParentEditModal extends StatefulWidget {
|
||||||
|
final ParentModel parent;
|
||||||
|
final VoidCallback? onSaved;
|
||||||
|
|
||||||
|
const AdminParentEditModal({
|
||||||
|
super.key,
|
||||||
|
required this.parent,
|
||||||
|
this.onSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminParentEditModal> createState() => _AdminParentEditModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _emailCtrl;
|
||||||
|
late final TextEditingController _telCtrl;
|
||||||
|
late final TextEditingController _adresseCtrl;
|
||||||
|
late final TextEditingController _villeCtrl;
|
||||||
|
late final TextEditingController _cpCtrl;
|
||||||
|
|
||||||
|
late String _statut;
|
||||||
|
late List<ParentChildSummary> _children;
|
||||||
|
bool _saving = false;
|
||||||
|
bool _dirty = false;
|
||||||
|
|
||||||
|
static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final u = widget.parent.user;
|
||||||
|
_nomCtrl = TextEditingController(text: u.nom ?? '');
|
||||||
|
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
|
||||||
|
_emailCtrl = TextEditingController(text: u.email);
|
||||||
|
_telCtrl = TextEditingController(text: u.telephone ?? '');
|
||||||
|
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
|
||||||
|
_villeCtrl = TextEditingController(text: u.ville ?? '');
|
||||||
|
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
|
||||||
|
_statut = u.statut ?? 'en_attente';
|
||||||
|
_children = List.of(widget.parent.children);
|
||||||
|
for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) {
|
||||||
|
c.addListener(_markDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markDirty() {
|
||||||
|
if (!_dirty) setState(() => _dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayStatus(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'en_attente':
|
||||||
|
return 'En attente';
|
||||||
|
case 'suspendu':
|
||||||
|
return 'Suspendu';
|
||||||
|
case 'refuse':
|
||||||
|
return 'Refusé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Confirmer'),
|
||||||
|
content: const Text('Enregistrer les modifications de la fiche parent ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateParentFiche(
|
||||||
|
parentUserId: widget.parent.user.id,
|
||||||
|
body: {
|
||||||
|
'nom': _nomCtrl.text.trim(),
|
||||||
|
'prenom': _prenomCtrl.text.trim(),
|
||||||
|
'email': _emailCtrl.text.trim(),
|
||||||
|
'telephone': _telCtrl.text.trim(),
|
||||||
|
'adresse': _adresseCtrl.text.trim(),
|
||||||
|
'ville': _villeCtrl.text.trim(),
|
||||||
|
'code_postal': _cpCtrl.text.trim(),
|
||||||
|
'statut': _statut,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Fiche parent enregistrée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openChild(ParentChildSummary child) async {
|
||||||
|
try {
|
||||||
|
final enfant = await UserService.getEnfant(child.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
|
enfant: enfant,
|
||||||
|
onSaved: () async {
|
||||||
|
await _reloadChildren();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadChildren() async {
|
||||||
|
try {
|
||||||
|
final refreshed = await UserService.getParent(widget.parent.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _children = List.of(refreshed.children));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detachChild(ParentChildSummary child) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Détacher l\'enfant'),
|
||||||
|
content: Text(
|
||||||
|
'Retirer ${child.fullName} de la fiche de ce parent ?\n(L\'enfant ne sera pas supprimé.)',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Détacher'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final updated = await UserService.detachEnfantFromParent(
|
||||||
|
parentUserId: widget.parent.user.id,
|
||||||
|
enfantId: child.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _children = List.of(updated.children));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant détaché')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _attachChild() async {
|
||||||
|
List<EnfantAdminModel> all;
|
||||||
|
try {
|
||||||
|
all = await UserService.getEnfants();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedIds = _children.map((c) => c.id).toSet();
|
||||||
|
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<EnfantAdminModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Rattacher un enfant'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(e) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, e),
|
||||||
|
child: Text(e.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final updated = await UserService.attachEnfantToParent(
|
||||||
|
parentUserId: widget.parent.user.id,
|
||||||
|
enfantId: selected.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _children = List.of(updated.children));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant rattaché')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 130,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: keyboard,
|
||||||
|
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final numero = widget.parent.user.numeroDossier;
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.parent.user.fullName.isEmpty
|
||||||
|
? 'Parent'
|
||||||
|
: widget.parent.user.fullName,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (numero != null && numero.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 130,
|
||||||
|
child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(numero)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 130,
|
||||||
|
child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: _statuts.contains(_statut) ? _statut : _statuts.first,
|
||||||
|
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||||
|
items: _statuts
|
||||||
|
.map(
|
||||||
|
(s) => DropdownMenuItem(
|
||||||
|
value: s,
|
||||||
|
child: Text(_displayStatus(s)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v == null) return;
|
||||||
|
setState(() {
|
||||||
|
_statut = v;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_field('Nom', _nomCtrl),
|
||||||
|
_field('Prénom', _prenomCtrl),
|
||||||
|
_field('Email', _emailCtrl, keyboard: TextInputType.emailAddress),
|
||||||
|
_field('Téléphone', _telCtrl, keyboard: TextInputType.phone),
|
||||||
|
if (_telCtrl.text.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 130, bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
formatPhoneForDisplay(_telCtrl.text),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_field('Adresse', _adresseCtrl),
|
||||||
|
_field('Ville', _villeCtrl),
|
||||||
|
_field('Code postal', _cpCtrl),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Nombre d\'enfants : ${_children.length}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: _children.isEmpty
|
||||||
|
? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54))
|
||||||
|
: Column(
|
||||||
|
children: _children
|
||||||
|
.map(
|
||||||
|
(c) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: InkWell(
|
||||||
|
onTap: () => _openChild(c),
|
||||||
|
child: Text(
|
||||||
|
c.fullName,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(_childStatusLabel(c.status)),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Détacher',
|
||||||
|
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
|
||||||
|
onPressed: () => _detachChild(c),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: _attachChild,
|
||||||
|
icon: const Icon(Icons.link),
|
||||||
|
label: const Text('Rattacher un enfant'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: !_dirty || _saving ? null : _save,
|
||||||
|
icon: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _childStatusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'a_naitre':
|
||||||
|
return 'À naître';
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'scolarise':
|
||||||
|
return 'Scolarisé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal file
120
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_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';
|
||||||
|
|
||||||
|
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
||||||
|
class EnfantManagementWidget extends StatefulWidget {
|
||||||
|
final String searchQuery;
|
||||||
|
final String? statusFilter;
|
||||||
|
|
||||||
|
const EnfantManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.statusFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<EnfantAdminModel> _enfants = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEnfants();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEnfants() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await UserService.getEnfants();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_enfants = list;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'a_naitre':
|
||||||
|
return 'À naître';
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'scolarise':
|
||||||
|
return 'Scolarisé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEnfant(EnfantAdminModel enfant) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
|
enfant: enfant,
|
||||||
|
onSaved: _loadEnfants,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final query = widget.searchQuery.toLowerCase();
|
||||||
|
final filtered = _enfants.where((e) {
|
||||||
|
final matchesName = e.fullName.toLowerCase().contains(query);
|
||||||
|
final matchesStatus =
|
||||||
|
widget.statusFilter == null || e.status == widget.statusFilter;
|
||||||
|
return matchesName && matchesStatus;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
error: _error,
|
||||||
|
isEmpty: filtered.isEmpty,
|
||||||
|
emptyMessage: 'Aucun enfant trouvé.',
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final enfant = filtered[index];
|
||||||
|
final parents = enfant.parentLinks
|
||||||
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
|
.join(', ');
|
||||||
|
return AdminUserCard(
|
||||||
|
title: enfant.fullName,
|
||||||
|
fallbackIcon: Icons.child_care_outlined,
|
||||||
|
avatarUrl: enfant.photoUrl,
|
||||||
|
subtitleLines: [
|
||||||
|
'Statut : ${_statusLabel(enfant.status)}',
|
||||||
|
if (enfant.birthDate != null && enfant.birthDate!.isNotEmpty)
|
||||||
|
'Naissance : ${enfant.birthDate}',
|
||||||
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.visibility_outlined),
|
||||||
|
tooltip: 'Voir / modifier',
|
||||||
|
onPressed: () => _openEnfant(enfant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
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/utils/phone_utils.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_parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_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';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
avatarUrl: parent.user.photoUrl,
|
avatarUrl: parent.user.photoUrl,
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
parent.user.email,
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
void _openParentDetails(ParentModel parent) {
|
void _openParentDetails(ParentModel parent) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminDetailModal(
|
builder: (context) => AdminParentEditModal(
|
||||||
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
|
parent: parent,
|
||||||
subtitle: parent.user.email,
|
onSaved: _loadParents,
|
||||||
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) != '–' ? formatPhoneForDisplay(_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')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.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/assistante_maternelle_management_widget.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/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_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/parent_managmant_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||||
@ -28,6 +29,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
|
String? _enfantStatus;
|
||||||
bool _hasPending = false;
|
bool _hasPending = false;
|
||||||
bool _pendingLoading = true;
|
bool _pendingLoading = true;
|
||||||
|
|
||||||
@ -80,7 +82,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> get _tabLabels {
|
List<String> get _tabLabels {
|
||||||
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
|
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
||||||
final withAdmin = [...base, 'Administrateurs'];
|
final withAdmin = [...base, 'Administrateurs'];
|
||||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||||
@ -94,11 +96,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
_subIndex = index.clamp(0, maxIndex);
|
_subIndex = index.clamp(0, maxIndex);
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_parentStatus = null;
|
_parentStatus = null;
|
||||||
|
_enfantStatus = null;
|
||||||
_amCapacityController.clear();
|
_amCapacityController.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
|
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
|
||||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||||
|
|
||||||
String _searchHintForTab() {
|
String _searchHintForTab() {
|
||||||
@ -109,10 +112,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
case 0:
|
case 0:
|
||||||
return 'Rechercher un parent...';
|
return 'Rechercher un parent...';
|
||||||
case 1:
|
case 1:
|
||||||
return 'Rechercher une assistante...';
|
return 'Rechercher un enfant...';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Rechercher un gestionnaire...';
|
return 'Rechercher une assistante...';
|
||||||
case 3:
|
case 3:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
|
case 4:
|
||||||
return 'Rechercher un administrateur...';
|
return 'Rechercher un administrateur...';
|
||||||
default:
|
default:
|
||||||
return 'Rechercher...';
|
return 'Rechercher...';
|
||||||
@ -176,6 +181,54 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 1) {
|
if (_subIndex == _contentIndexOffset + 1) {
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: _enfantStatus,
|
||||||
|
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: 'a_naitre',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('À naître', 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: 'scolarise',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Scolarisé', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_enfantStatus = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_subIndex == _contentIndexOffset + 2) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _amCapacityController,
|
controller: _amCapacityController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@ -203,16 +256,21 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
|
return EnfantManagementWidget(
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
statusFilter: _enfantStatus,
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
return GestionnaireManagementWidget(
|
return GestionnaireManagementWidget(
|
||||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return AdminManagementWidget(
|
return AdminManagementWidget(
|
||||||
key: ValueKey('admins-$_adminRefreshTick'),
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
@ -246,7 +304,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
if (contentIndex == 2) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -264,7 +322,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 4) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -289,7 +347,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'La création est disponible pour les gestionnaires et administrateurs.',
|
'La création parent / enfant / AM sera disponible avec le ticket #129.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user