Compare commits

...
Author SHA1 Message Date
jmartin 3218daa12e merge(#164): uniformisation modale staff gestionnaire / admin. 2026-09-14 17:29:31 +02:00
jmartinandCursor 1d6261b312 feat(#164): uniformise la modale staff (gestionnaire / admin).
Shell 930 + Validation*Field, dirty-state, StaffUserFormModal sous
widgets/dashboard/ ; retire AdminUserFormDialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:29:13 +02:00
jmartin 9557cf9947 merge(#155): rename Admin* + panels sous widgets/dashboard/. 2026-09-14 12:46:46 +02:00
jmartinandCursor 939e7777ac docs(#155): mini-spec phase 2 — move panels admin → dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:47 +02:00
jmartinandCursor b474842e19 refactor(#155): phase 2 — panels staff sous widgets/dashboard/.
Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:23 +02:00
jmartinandCursor a312d9c0fa refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers
widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …).
Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique,
aucun changement de comportement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 10:57:58 +02:00
jmartin b5b32062b3 merge(#152): suppression grossesse multiple / est_multiple. 2026-09-14 10:57:28 +02:00
jmartinandCursor 946d8edcd2 feat(#152): suppression complète grossesse multiple / est_multiple.
Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest,
modèles et payloads Flutter, scripts de test et docs. Back et front alignés
(forbidNonWhitelisted). Aucun fantôme de compat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:08:36 +02:00
jmartin c8cb82dd24 merge(#161): admin peut créer gestionnaire et administrateur. 2026-09-10 23:00:28 +02:00
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin
(+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le
dashboard gestionnaire. Tests droits Roles + createAdmin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
jmartinandCursor 67336c64fe fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:38:57 +02:00
jmartinandCursor 97afbbcf9a fix(am): hauteur modale fiche AM par onglet (sans scroll inutile).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:06 +02:00
jmartinandCursor e235b30140 feat(#160): suppressions dashboard — poubelle, confirmations et sans_enfant.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:05 +02:00
jmartinandCursor 7f23356273 feat(#159): suppressions métier dossiers/parents/enfants/AM/staff.
SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et
DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:01:44 +02:00
jmartinandCursor c8c9cfbc4d feat(#135): mode édition dossier (PATCH fiche, co-parent, enfants).
Wizard edit famille/AM depuis la liste Dossiers ; save parents +
co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:23 +02:00
jmartinandCursor 530e896b66 feat(#135): POST /parents/:id/co-parent — ajout co-parent foyer.
API staff pour foyer mono-parent : compte actif, liens + enfants,
mail création MDP. Mini-specs front/back dans docs/tmp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:34:19 +02:00
107 changed files with 5297 additions and 1338 deletions
-1
View File
@@ -33,7 +33,6 @@ model Child {
dateOfBirth DateTime dateOfBirth DateTime
photoUrl String? photoUrl String?
photoConsent Boolean @default(false) photoConsent Boolean @default(false)
isMultiple Boolean @default(false)
isUnborn Boolean @default(false) isUnborn Boolean @default(false)
parentId String parentId String
parent Parent @relation(fields: [parentId], references: [id]) parent Parent @relation(fields: [parentId], references: [id])
+2
View File
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux'; import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module'; import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module'; import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({ @Module({
imports: [ imports: [
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
DocumentsLegauxModule, DocumentsLegauxModule,
RelaisModule, RelaisModule,
DossiersModule, DossiersModule,
SuppressionsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
-3
View File
@@ -63,9 +63,6 @@ export class Children {
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' }) @Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date; consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents // Lien via table de jointure enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.child) @OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[]; parentLinks: ParentsChildren[];
+162 -4
View File
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service'; import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service'; import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -563,7 +564,6 @@ export class AuthService {
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant); const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre); enfantsEnregistres.push(enfantEnregistre);
@@ -718,6 +718,167 @@ export class AuthService {
}); });
} }
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) {
this.logger.error(
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
err instanceof Error ? err.stack : String(err),
);
}
return {
message:
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
};
}
/** /**
* Cœur partagé création dossier AM (#156). * Cœur partagé création dossier AM (#156).
* - public : statut en_attente + mail pending * - public : statut en_attente + mail pending
@@ -1225,9 +1386,6 @@ export class AuthService {
enfant.status = StatutEnfantType.A_NAITRE; enfant.status = StatutEnfantType.A_NAITRE;
} }
} }
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.consent_photo !== undefined) { if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo enfant.consent_photo_at = enfant.consent_photo
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
@IsString() @IsString()
photo_filename?: string; photo_filename?: string;
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
@ApiProperty({ @ApiProperty({
example: true, example: true,
required: false, required: false,
@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller'; import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service'; import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
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 { StatutUtilisateurType } from 'src/entities/users.entity'; import { StatutUtilisateurType } from 'src/entities/users.entity';
@@ -11,11 +12,17 @@ describe('DossiersController', () => {
listDossiers: jest.fn(), listDossiers: jest.fn(),
getDossierByNumero: jest.fn(), getDossierByNumero: jest.fn(),
}; };
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController], controllers: [DossiersController],
providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
}) })
.overrideGuard(AuthGuard) .overrideGuard(AuthGuard)
.useValue({ canActivate: () => true }) .useValue({ canActivate: () => true })
@@ -60,4 +67,19 @@ describe('DossiersController', () => {
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
expect(res.type).toBe('family'); expect(res.type).toBe('family');
}); });
it('remove delegates to suppressionService.deleteDossier', async () => {
const user = { id: 'u1', role: 'gestionnaire' } as never;
suppressionServiceMock.deleteDossier.mockResolvedValue({
type: 'famille',
deleted_user_ids: [],
deleted_enfant_ids: [],
message: 'ok',
});
await controller.remove('2026-000001', user);
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
'2026-000001',
user,
);
});
}); });
@@ -1,4 +1,11 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
@@ -8,10 +15,12 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
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 { DossiersService } from './dossiers.service'; import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto';
@@ -20,7 +29,10 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto';
@Controller('dossiers') @Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
export class DossiersController { export class DossiersController {
constructor(private readonly dossiersService: DossiersService) {} constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -28,7 +40,8 @@ export class DossiersController {
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153', summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
description: description:
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' + '1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant.', 'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
}) })
@ApiQuery({ @ApiQuery({
name: 'q', name: 'q',
@@ -51,4 +64,21 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> { getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier); return this.dossiersService.getDossierByNumero(numeroDossier);
} }
@Delete(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Supprimer un dossier (famille ou AM) — #159',
description:
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
})
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
remove(
@Param('numeroDossier') numeroDossier: string,
@User() currentUser: Users,
) {
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
}
} }
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module'; import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller'; import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service'; import { DossiersService } from './dossiers.service';
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
imports: [ imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]), TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule, ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -4,6 +4,7 @@ import { DossiersService } from './dossiers.service';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service'; import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { StatutUtilisateurType } from 'src/entities/users.entity'; import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersService.listDossiers', () => { describe('DossiersService.listDossiers', () => {
@@ -32,6 +33,9 @@ describe('DossiersService.listDossiers', () => {
const parentsService = { const parentsService = {
getDossierFamilleByNumero: jest.fn(), getDossierFamilleByNumero: jest.fn(),
}; };
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@@ -40,6 +44,7 @@ describe('DossiersService.listDossiers', () => {
{ provide: getRepositoryToken(Parents), useValue: parentsRepo }, { provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService }, { provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
], ],
}).compile(); }).compile();
@@ -47,6 +52,7 @@ describe('DossiersService.listDossiers', () => {
jest.clearAllMocks(); jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb); amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
}); });
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
@@ -5,12 +5,13 @@ import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { ParentsService } from '../parents/parents.service'; import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto';
/** /**
* Dossiers unifiés — détail (#119) + liste (#153). * Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
*/ */
@Injectable() @Injectable()
export class DossiersService { export class DossiersService {
@@ -20,6 +21,7 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle) @InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>, private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService, private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {} ) {}
/** /**
@@ -43,6 +45,17 @@ export class DossiersService {
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
}); });
for (const item of filtered) {
if (item.type === 'famille') {
const n = await this.suppressionService.countEnfantsForNumero(
item.numero_dossier,
);
item.sans_enfant = n === 0;
} else {
item.sans_enfant = false;
}
}
return filtered; return filtered;
} }
@@ -49,4 +49,10 @@ export class DossierListItemDto {
description: 'Date de référence (MIN cree_le des users du dossier)', description: 'Date de référence (MIN cree_le des users du dossier)',
}) })
date_reference: string | null; date_reference: string | null;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
} }
@@ -74,11 +74,6 @@ export class CreateEnfantsDto {
@IsDateString() @IsDateString()
consent_photo_at?: string; consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/** /**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132). * Ignoré / interdit en externe pour un PARENT (ticket #132).
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
@ApiProperty({ example: false }) @ApiProperty({ example: false })
consent_photo: boolean; consent_photo: boolean;
@ApiProperty({ example: false })
is_multiple: boolean;
@ApiProperty({ example: 'UUID-parent' }) @ApiProperty({ example: 'UUID-parent' })
parent_id: string; parent_id: string;
} }
@@ -13,6 +13,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -23,6 +24,7 @@ import {
ApiBody, ApiBody,
ApiConsumes, ApiConsumes,
ApiOperation, ApiOperation,
ApiQuery,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
@@ -36,6 +38,7 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = { const photoMulterOptions = {
storage: diskStorage({ storage: diskStorage({
@@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('enfants') @Controller('enfants')
export class EnfantsController { export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { } constructor(
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
@Roles( @Roles(
RoleType.PARENT, RoleType.PARENT,
@@ -141,17 +147,47 @@ export class EnfantsController {
RoleType.GESTIONNAIRE, RoleType.GESTIONNAIRE,
) )
@Patch(':id') @Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update( update(
@Param('id', new ParseUUIDPipe()) id: string, @Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto, @Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users, @User() currentUser: Users,
) { ) {
return this.enfantsService.update(id, dto, currentUser); return this.enfantsService.update(id, dto, currentUser, photo);
} }
@Roles(RoleType.SUPER_ADMIN) @Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Delete(':id') @Delete(':id')
remove(@Param('id', new ParseUUIDPipe()) id: string) { @ApiOperation({
return this.enfantsService.remove(id); summary: 'Supprimer un enfant (#159)',
description:
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
})
@ApiQuery({
name: 'deleteDossier',
required: false,
description: 'Si true et dernier enfant : cascade dossier famille',
})
remove(
@Param('id', new ParseUUIDPipe()) id: string,
@Query('deleteDossier') deleteDossier: string | undefined,
@User() currentUser: Users,
) {
const flag =
deleteDossier === 'true' ||
deleteDossier === '1' ||
deleteDossier === 'yes';
return this.suppressionService.deleteEnfant(id, flag, currentUser);
} }
} }
+7 -4
View File
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), imports: [
AuthModule TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule,
SuppressionsModule,
], ],
controllers: [EnfantsController], controllers: [EnfantsController],
providers: [EnfantsService] providers: [EnfantsService],
exports: [EnfantsService],
}) })
export class EnfantsModule { } export class EnfantsModule { }
+13 -2
View File
@@ -92,7 +92,6 @@ export class EnfantsService {
photo_url: photoUrl, photo_url: photoUrl,
consent_photo: !!dto.consent_photo, consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt, consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
}); });
await this.childrenRepository.save(child); await this.childrenRepository.save(child);
@@ -195,7 +194,12 @@ export class EnfantsService {
// Mise à jour // Mise à jour
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> { async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
@@ -205,6 +209,13 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo; patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
} }
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch); await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser); return this.findOne(id, currentUser);
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
description: 'Consentement affichage photo (colonne consentement_photo)', description: 'Consentement affichage photo (colonne consentement_photo)',
}) })
consent_photo?: boolean; consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
} }
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */ /** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) — ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -11,6 +11,7 @@ describe('ParentsController', () => {
let controller: ParentsController; let controller: ParentsController;
const authServiceMock = { const authServiceMock = {
createParentDossierStaff: jest.fn(), createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
}; };
const parentsServiceMock = {}; const parentsServiceMock = {};
const userServiceMock = {}; const userServiceMock = {};
@@ -77,4 +78,27 @@ describe('ParentsController', () => {
expect(res.enfant_ids).toEqual(['e1']); expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF); expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
}); });
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
}); });
@@ -30,6 +30,8 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.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';
@@ -176,6 +178,28 @@ export class ParentsController {
return mapParentForApi(parent); return mapParentForApi(parent);
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId') @Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@@ -370,7 +370,6 @@ export class ParentsService {
status: child.status, status: child.status,
photo_url: child.photo_url ?? undefined, photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo, consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
}; };
} }
@@ -0,0 +1,175 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { SuppressionService } from './suppression.service';
import { RoleType } from 'src/entities/users.entity';
describe('SuppressionService (#159)', () => {
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
),
};
const usersRepository = {
findOne: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
const parentsRepository = {
findOne: jest.fn(),
find: jest.fn(),
query: jest.fn(),
};
const amRepository = {
findOne: jest.fn(),
};
const childrenRepository = {
findOne: jest.fn(),
delete: jest.fn(),
save: jest.fn(),
};
const parentsChildrenRepository = {
find: jest.fn(),
};
const amChildrenRepository = {
find: jest.fn(),
save: jest.fn(),
count: jest.fn(),
};
let service: SuppressionService;
const staff = {
id: 'staff-1',
role: RoleType.GESTIONNAIRE,
} as never;
const admin = {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never;
const superAdmin = {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never;
beforeEach(() => {
jest.clearAllMocks();
service = new SuppressionService(
dataSource as never,
usersRepository as never,
parentsRepository as never,
amRepository as never,
childrenRepository as never,
parentsChildrenRepository as never,
amChildrenRepository as never,
);
});
it('refuse self-delete', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
});
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('refuse gestionnaire deleting another gestionnaire', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'g2',
role: RoleType.GESTIONNAIRE,
});
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : refus si pas super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : OK pour super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('admin-2', superAdmin);
expect(res.deleted_user_ids).toEqual(['admin-2']);
});
it('delete AM : clos placements, pas denfants deleted', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'am-1',
role: RoleType.ASSISTANTE_MATERNELLE,
});
amRepository.findOne.mockResolvedValue({
user_id: 'am-1',
numero_dossier: '2026-000015',
});
amChildrenRepository.find.mockResolvedValue([
{
amId: 'am-1',
enfantId: 'e1',
child: { id: 'e1', status: 'garde' },
},
]);
amChildrenRepository.count.mockResolvedValue(0);
amChildrenRepository.save.mockImplementation(async (x) => x);
childrenRepository.save.mockResolvedValue({});
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('am-1', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['am-1']);
expect(res.type).toBe('assistante_maternelle');
});
it('delete dossier famille introuvable', async () => {
parentsRepository.findOne.mockResolvedValue(null);
amRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteDossier('2026-999999', staff),
).rejects.toBeInstanceOf(NotFoundException);
});
it('co-parent : delete user seul', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'p2',
role: RoleType.PARENT,
});
parentsRepository.findOne.mockResolvedValue({
user_id: 'p2',
numero_dossier: '2026-000010',
co_parent: { id: 'p1' },
});
parentsRepository.query.mockResolvedValue([
{ id: 'p1' },
{ id: 'p2' },
]);
dataSource.transaction.mockImplementation(async (cb) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
);
const res = await service.deleteUser('p2', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['p2']);
expect(res.message).toMatch(/co-parent/i);
});
});
@@ -0,0 +1,420 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
export type SuppressionResult = {
type?: 'famille' | 'assistante_maternelle';
numero_dossier?: string;
deleted_user_ids: string[];
deleted_enfant_ids: string[];
dossier_supprime?: boolean;
message: string;
};
const STAFF_METIER: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
/**
* Cascades de suppression métier tickets #154 / #159.
*/
@Injectable()
export class SuppressionService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {}
assertStaffMetier(currentUser: Users): void {
if (!STAFF_METIER.includes(currentUser.role)) {
throw new ForbiddenException('Accès refusé');
}
}
async deleteDossier(
numeroDossier: string,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const num = numeroDossier?.trim();
if (!num) {
throw new BadRequestException('Numéro de dossier requis.');
}
const parentHit = await this.parentsRepository.findOne({
where: { numero_dossier: num },
});
if (parentHit) {
return this.deleteFamilleByNumero(num);
}
const amHit = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (amHit?.user) {
return this.deleteAmUser(amHit.user.id);
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
async deleteUser(
id: string,
currentUser: Users,
): Promise<SuppressionResult> {
const target = await this.usersRepository.findOne({ where: { id } });
if (!target) {
throw new NotFoundException('Utilisateur introuvable');
}
if (target.id === currentUser.id) {
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
}
if (target.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
}
if (target.role === RoleType.PARENT) {
this.assertStaffMetier(currentUser);
return this.deleteParentUser(target.id);
}
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
this.assertStaffMetier(currentUser);
return this.deleteAmUser(target.id);
}
if (target.role === RoleType.GESTIONNAIRE) {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un gestionnaire.',
);
}
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Gestionnaire supprimé.',
};
}
if (target.role === RoleType.ADMINISTRATEUR) {
await this.assertCanDeleteAdministrateur(target, currentUser);
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Administrateur supprimé.',
};
}
throw new BadRequestException('Type dutilisateur non supprimable via cet endpoint.');
}
async deleteEnfant(
enfantId: string,
deleteDossier: boolean,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const child = await this.childrenRepository.findOne({
where: { id: enfantId },
relations: ['parentLinks', 'parentLinks.parent'],
});
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const parentIds = (child.parentLinks ?? [])
.map((l) => l.parentId ?? l.parent?.user_id)
.filter(Boolean) as string[];
let numero: string | undefined;
if (parentIds.length > 0) {
const parents = await this.parentsRepository.find({
where: { user_id: In(parentIds) },
});
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
}
if (!numero) {
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
message: 'Enfant supprimé.',
};
}
const siblingIds = await this.listEnfantIdsForNumero(numero);
const isLast = siblingIds.length <= 1;
if (isLast && deleteDossier) {
const result = await this.deleteFamilleByNumero(numero);
return {
...result,
dossier_supprime: true,
message: 'Dernier enfant et dossier famille supprimés.',
};
}
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
numero_dossier: numero,
type: 'famille',
message: isLast
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
: 'Enfant supprimé du dossier famille.',
};
}
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
const ids = await this.listEnfantIdsForNumero(numeroDossier);
return ids.length;
}
private async assertCanDeleteAdministrateur(
target: Users,
currentUser: Users,
): Promise<void> {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un administrateur.',
);
}
const adminCount = await this.usersRepository.count({
where: { role: RoleType.ADMINISTRATEUR },
});
if (adminCount <= 1) {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Seul le super administrateur peut supprimer le dernier administrateur.',
);
}
}
}
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
const parent = await this.parentsRepository.findOne({
where: { user_id: userId },
relations: ['co_parent'],
});
if (!parent) {
// Compte parent sans fiche — hard delete user
await this.usersRepository.delete(userId);
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Parent supprimé.',
};
}
const numero = parent.numero_dossier?.trim();
const foyerIds = numero
? await this.listParentUserIdsForNumero(numero)
: [userId];
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
if (!isLast) {
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
await this.dataSource.transaction(async (manager) => {
await manager.delete(ParentsChildren, { parentId: userId });
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
numero_dossier: numero,
type: 'famille',
message: 'Parent retiré du dossier (co-parent).',
};
}
// Dernier parent : + enfants
const enfantIds = numero
? await this.listEnfantIdsForNumero(numero)
: await this.listEnfantIdsForParent(userId);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: enfantIds,
numero_dossier: numero,
type: 'famille',
message: 'Dernier parent et enfants rattachés supprimés.',
};
}
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (parentIds.length === 0) {
throw new NotFoundException('Aucun parent pour ce dossier.');
}
const enfantIds = await this.listEnfantIdsForNumero(numero);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
[parentIds],
);
await manager.delete(Users, { id: In(parentIds) });
});
return {
type: 'famille',
numero_dossier: numero,
deleted_user_ids: parentIds,
deleted_enfant_ids: enfantIds,
message: 'Dossier famille supprimé.',
};
}
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
const am = await this.amRepository.findOne({ where: { user_id: userId } });
const numero = am?.numero_dossier?.trim();
const active = await this.amChildrenRepository.find({
where: { amId: userId, date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of active) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
await this.usersRepository.delete(userId);
return {
type: 'assistante_maternelle',
numero_dossier: numero,
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Dossier assistante maternelle supprimé.',
};
}
private async applySansGarde(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
const remaining = await this.amChildrenRepository.count({
where: { enfantId: child.id, date_fin: IsNull() },
});
if (remaining === 0) {
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
}
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
if (!enfantIds.length) return;
const links = await this.amChildrenRepository.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of links) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
}
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
const rows: Array<{ id: string }> = await this.parentsRepository.query(
`
SELECT DISTINCT x.id::text AS id FROM (
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
UNION
SELECT id_co_parent AS id FROM parents
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
UNION
SELECT p2.id_utilisateur AS id FROM parents p1
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
WHERE TRIM(p1.numero_dossier) = $1
) x WHERE x.id IS NOT NULL
`,
[numero],
);
return rows.map((r) => r.id);
}
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (!parentIds.length) return [];
return this.listEnfantIdsForParents(parentIds);
}
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
return this.listEnfantIdsForParents([parentId]);
}
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
const links = await this.parentsChildrenRepository.find({
where: { parentId: In(parentIds) },
});
return [...new Set(links.map((l) => l.enfantId))];
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { SuppressionService } from './suppression.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Users,
Parents,
AssistanteMaternelle,
Children,
ParentsChildren,
AmChildren,
]),
],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionsModule {}
@@ -1,20 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing'; import 'reflect-metadata';
import { GestionnairesController } from './gestionnaires.controller'; import { GestionnairesController } from './gestionnaires.controller';
import { GestionnairesService } from './gestionnaires.service'; import { RoleType } from 'src/entities/users.entity';
describe('GestionnairesController', () => { describe('GestionnairesController roles (#161)', () => {
let controller: GestionnairesController; it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
beforeEach(async () => { expect(roles).toEqual(
const module: TestingModule = await Test.createTestingModule({ expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
controllers: [GestionnairesController], );
providers: [GestionnairesService], expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
}); });
it('should be defined', () => { it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
expect(controller).toBeDefined(); const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}); });
}); });
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController { export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { } constructor(private readonly gestionnairesService: GestionnairesService) { }
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users }) @ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' }) @ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
@ApiOperation({ summary: 'Création d\'un gestionnaire' }) @ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
@ApiBody({ type: CreateGestionnaireDto }) @ApiBody({ type: CreateGestionnaireDto })
@Post() @Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> { create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll(); return this.gestionnairesService.findAll();
} }
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN) @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' }) @ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' }) @ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id); return this.gestionnairesService.findOne(id);
} }
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' }) @ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users }) @ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' }) @ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,20 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing'; import 'reflect-metadata';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { UserService } from './user.service'; import { RoleType } from 'src/entities/users.entity';
describe('UserController', () => { describe('UserController roles (#161)', () => {
let controller: UserController; it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
beforeEach(async () => { expect(roles).toEqual(
const module: TestingModule = await Test.createTestingModule({ expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
controllers: [UserController], );
providers: [UserService], expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
}); });
}); });
+20 -8
View File
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto'; import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto'; import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto'; import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs') @ApiTags('Utilisateurs')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('users') @Controller('users')
export class UserController { export class UserController {
constructor(private readonly userService: UserService) { } constructor(
private readonly userService: UserService,
private readonly suppressionService: SuppressionService,
) { }
// Création d'un administrateur (réservée aux super admins) // Création d'un administrateur (admin + super admin) — #161
@Post('admin') @Post('admin')
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' }) @ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
createAdmin( createAdmin(
@Body() dto: CreateAdminDto, @Body() dto: CreateAdminDto,
@User() currentUser: Users @User() currentUser: Users
@@ -146,12 +150,20 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment); return this.userService.suspendUser(id, currentUser, comment);
} }
// Supprimer un utilisateur (super_admin uniquement) // Supprimer un utilisateur — cascades métier #159
@Delete(':id') @Delete(':id')
@Roles(RoleType.SUPER_ADMIN) @Roles(
@ApiOperation({ summary: 'Supprimer un utilisateur' }) RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@ApiOperation({
summary: 'Supprimer un utilisateur (cascades métier #159)',
description:
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
})
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
remove(@Param('id') id: string, @User() currentUser: Users) { remove(@Param('id') id: string, @User() currentUser: Users) {
return this.userService.remove(id, currentUser); return this.suppressionService.deleteUser(id, currentUser);
} }
} }
+2
View File
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module'; import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module'; import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature( imports: [TypeOrmModule.forFeature(
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
GestionnairesModule, GestionnairesModule,
MailModule, MailModule,
AppConfigModule, AppConfigModule,
SuppressionsModule,
], ],
controllers: [UserController], controllers: [UserController],
providers: [UserService], providers: [UserService],
+78 -9
View File
@@ -1,18 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
describe('UserService.createAdmin (#161)', () => {
const usersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
describe('UserService', () => {
let service: UserService; let service: UserService;
beforeEach(async () => { const dto = {
const module: TestingModule = await Test.createTestingModule({ email: 'nouveau.admin@ptits-pas.fr',
providers: [UserService], password: 'Password1!',
}).compile(); prenom: 'Nina',
nom: 'Admin',
telephone: '0601020304',
};
service = module.get<UserService>(UserService); beforeEach(() => {
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}); });
it('should be defined', () => { it('autorise un administrateur à créer un admin', async () => {
expect(service).toBeDefined(); usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
const result = await service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never);
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
expect(usersRepository.save).toHaveBeenCalled();
});
it('autorise un super_admin à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
await expect(
service.createAdmin(dto as never, {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never),
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
});
it('refuse un gestionnaire (403 métier)', async () => {
await expect(
service.createAdmin(dto as never, {
id: 'gest-1',
role: RoleType.GESTIONNAIRE,
} as never),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersRepository.save).not.toHaveBeenCalled();
});
it('refuse un email déjà utilisé', async () => {
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
await expect(
service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });
+9 -2
View File
@@ -117,8 +117,14 @@ export class UserService {
} }
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> { async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) { // #161 — admin et super_admin peuvent créer un administrateur
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur'); if (
currentUser.role !== RoleType.SUPER_ADMIN &&
currentUser.role !== RoleType.ADMINISTRATEUR
) {
throw new ForbiddenException(
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
);
} }
const exist = await this.usersRepository.findOneBy({ email: dto.email }); const exist = await this.usersRepository.findOneBy({ email: dto.email });
@@ -520,6 +526,7 @@ export class UserService {
} }
async remove(id: string, currentUser: Users): Promise<void> { async remove(id: string, currentUser: Users): Promise<void> {
// Délégué historiquement ; préférer SuppressionService via controller (#159).
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
+1 -2
View File
@@ -174,8 +174,7 @@ CREATE TABLE enfants (
date_prevue_naissance DATE, date_prevue_naissance DATE,
photo_url TEXT, photo_url TEXT,
consentement_photo BOOLEAN DEFAULT false, consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ, date_consentement_photo TIMESTAMPTZ
est_multiple BOOLEAN DEFAULT false
); );
-- ========================================================== -- ==========================================================
+10 -10
View File
@@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" "id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False "5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False "a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False "e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False "e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False "e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False "e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False "e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False "e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False "edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
1 id statut prenom nom genre date_naissance date_prevue_naissance photo_url consentement_photo date_consentement_photo est_multiple
2 5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf sans_garde Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d sans_garde 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c sans_garde Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d sans_garde Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e sans_garde Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f sans_garde Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a sans_garde Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b sans_garde Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False
@@ -0,0 +1,2 @@
-- #152 — Suppression grossesse multiple / est_multiple
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
+4 -4
View File
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------ -- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------ -- ------------------------------------------------------------
+7 -7
View File
@@ -49,14 +49,14 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ========== -- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
VALUES VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ========== -- ========== ENFANTS_PARENTS (liaison N:N) ==========
-1
View File
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
| `photo_url` | TEXT | | URL de la photo | | `photo_url` | TEXT | | URL de la photo |
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo | | `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement | | `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
--- ---
-3
View File
@@ -276,9 +276,6 @@ export class Enfants {
@Column({ name: 'consentement_photo', type: 'boolean', default: false }) @Column({ name: 'consentement_photo', type: 'boolean', default: false })
consentementPhoto: boolean; consentementPhoto: boolean;
@Column({ name: 'est_multiple', type: 'boolean', default: false })
estMultiple: boolean;
@Column({ @Column({
type: 'enum', type: 'enum',
enum: StatutEnfantType, enum: StatutEnfantType,
@@ -0,0 +1,73 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -0,0 +1,83 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
@@ -0,0 +1,65 @@
# Mini-spec — Suppression complète grossesse multiple / `est_multiple`
**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152
**Branche** : `feature/152-remove-est-multiple` (depuis `develop`)
**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.**
---
## Décision
On **supprime tout**. Pas de DTO « ignorés », pas de compat payload.
`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement).
---
## Alias retirés
`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple`
---
## Back / BDD (fait sur la branche)
- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql`
- [x] `BDD.sql`, seeds, CSV test
- [x] Entity `Children` sans colonne
- [x] DTO create/inscription/réponse/dossier famille **sans** le champ
- [x] Services auth / enfants / parents : plus de mapping
- [x] Prisma legacy `isMultiple` retiré
## Front (fait sur la branche)
- [x] Modèles admin / dossier / inscription
- [x] Payloads inscription + reprise
- [x] Modale enfant + wizard dossier famille
- [x] Step3 inscription parent
## Scripts / docs
- [x] `tests/scripts/register-parent-*.mjs`
- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md`
- Docs tmp/archive #112 : mentions historiques OK (archive)
---
## Déploiement
1. Appliquer la migration SQL sur la BDD vivante
2. Deploy back **et** front de cette branche
3. Smoke : création enfant staff, inscription parent, reprise, wizard famille
## Vérif
```bash
rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \
backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md
```
**0** hit (hors ce fichier mini-spec / archives).
## Hors scope
- Métier futur « fratrie / jumeaux » → nouveau ticket
- #155 rename Admin*
- Ticket modales staff
+35
View File
@@ -0,0 +1,35 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail de suppression (cas rare)
## Droits
| Cible | Qui peut supprimer |
|-------|-------------------|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
## Matrice métier
| Point dentrée | Action | Effet |
|----------------|--------|--------|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
| Parents | Dernier parent | Parent + **enfants** rattachés |
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
## Warning
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
- Miroir de `sans_responsable` (#157) côté enfants.
## Hors scope
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
+169
View File
@@ -0,0 +1,169 @@
# Mini-spec front — Suppressions dashboard
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
**Epic** : #154 (complète #133)
**Branche back** : `feature/159-suppressions-backend`
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
---
## UX commune
Sur chaque ligne / carte des listes :
- Icône **poubelle** en bout de ligne
- Clic → **dialog de confirmation** (texte dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| Liste | Poubelle visible si |
|-------|---------------------|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
| Gestionnaires | **admin** only |
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
---
## Contrat API
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
### 1. `DELETE /dossiers/:numeroDossier`
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
**Réponse 200** (exemple) :
```json
{
"type": "famille",
"numero_dossier": "2026-000043",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "Dossier famille supprimé."
}
```
ou
```json
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000015",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": [],
"message": "Dossier assistante maternelle supprimé."
}
```
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
---
### 2. `DELETE /users/:id`
Comportement selon le **rôle** de la cible :
| Cible | Effet |
|-------|--------|
| Parent **co-parent** | Delete ce user seul |
| Parent **dernier** du dossier | Delete user + enfants du foyer |
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
| Gestionnaire | Admin only ; self → 403 |
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
**Réponse 200** :
```json
{
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "…"
}
```
**Dialogs** :
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
---
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
**Réponse 200** :
```json
{
"deleted_enfant_ids": ["…"],
"deleted_user_ids": ["…"],
"dossier_supprime": false,
"message": "…"
}
```
**Dialog** :
- Standard : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant seulement (`deleteDossier=false`)
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
---
### 4. `GET /dossiers` — flag `sans_enfant`
Chaque item famille peut exposer :
```json
"sans_enfant": true
```
- `true` si dossier **famille** sans enfant lié.
- AM : `false` ou omis.
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
---
## UserService (Flutter) — signatures cibles
```dart
Future<void> deleteDossier(String numeroDossier);
Future<Map<String, dynamic>> deleteUser(String userId);
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
```
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
---
## Fichiers front probables
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
- `user_service.dart`
---
## Critères front (#160)
- [ ] Poubelle selon droits
- [ ] Confirmations avec impact
- [ ] Refresh après succès
- [ ] Dernier enfant : choix dossier oui/non
- [ ] Warning `sans_enfant`
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
@@ -0,0 +1,54 @@
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
**Ticket** : **#155**
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
---
## Principe
Les widgets partagés **admin + gestionnaire** ne doivent plus sappeler `Admin*`.
On garde `Admin*` seulement là où cest vraiment le rôle administrateur.
## Gardé `Admin*` (hors rename)
| Élément | Raison |
|---------|--------|
| `AdminManagementWidget` | Onglet **Administrateurs** |
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
## Renames faits
| Avant | Après |
|-------|--------|
| `widgets/admin/common/admin_child_detail_modal.dart``AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart``ChildDetailModal` |
| `admin_am_edit_modal``AdminAmEditModal` | `am_edit_modal``AmEditModal` |
| `admin_parent_edit_modal``AdminParentEditModal` | `parent_edit_modal``ParentEditModal` |
| `admin_user_card``AdminUserCard` | `user_card``UserCard` |
| `admin_enfant_user_card``AdminEnfantUserCard` | `enfant_user_card``EnfantUserCard` |
| `admin_am_photo_frame``AdminAmPhotoFrame` | `am_photo_frame``AmPhotoFrame` |
| `admin_am_children_capacity_grid` | `am_children_capacity_grid``AmChildrenCapacityGrid` |
| `admin_children_affiliation_panel` | `children_affiliation_panel``ChildrenAffiliationPanel` |
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
| `admin_status_capsule` | `status_capsule``StatusCapsule` |
| `admin_list_state``AdminListState` | `user_list_state``UserListState` |
| `admin_detail_modal``AdminDetailModal` / `AdminDetailField` | `detail_modal``DetailModal` / `DetailField` |
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
## Hors scope
- Refonte UX modales (ticket dédié)
- Rename API / back
- Déplacer tout `widgets/admin/``widgets/dashboard/` (panels) — possible follow-up
## Critères
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
- [x] Pas de changement comportemental (rename mécanique)
@@ -0,0 +1,203 @@
# Mini-spec — Déplacer les panels `widgets/admin/``widgets/dashboard/`
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
**Phase 1** : widgets `Admin*``widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
**Nature** : rename / move mécanique — **zéro** changement UX / métier
---
## Contexte
Après la phase 1 (#155), la situation est **hybride** :
| Emplacement | Contenu |
|-------------|---------|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen``UserManagementPanel`).
Ce ticket **termine loption C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
---
## Objectif
```
frontend/lib/widgets/admin/<panels & common partagés>
↓ git mv + update imports
frontend/lib/widgets/dashboard/…
```
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
---
## Cible darborescence (proposée)
```
widgets/dashboard/
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
├── user_management_panel.dart ← shell onglets
├── user_management_sub_bar.dart ← déjà déplacé #155
├── dossiers_management_widget.dart
├── dossier_list_card.dart
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
├── enfant_management_widget.dart
├── assistante_maternelle_management_widget.dart
├── gestionnaire_management_widget.dart
├── pending_validation_widget.dart
├── parent_dossier_create_modal.dart
├── parent_dossier_wizard.dart
├── am_dossier_create_modal.dart
├── am_dossier_wizard.dart
├── validation_*.dart ← family/am wizards, refus, theme, confirm
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
├── relais_management_panel.dart
├── common/ ← sous-dossier optionnel
│ ├── suppression_confirm_dialog.dart
│ ├── user_list.dart
│ └── validation_detail_section.dart
└── …
widgets/admin/ ← mince, rôle admin seulement
└── admin_management_widget.dart ← onglet Administrateurs
```
### Variante B (plus stricte)
`AdminManagementWidget` + éventuels helpers purement admin →
`screens/administrateurs/widgets/`
et **suppression** du dossier `widgets/admin/`.
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
---
## Inventaire à déplacer (état actuel)
### Racine `widgets/admin/``widgets/dashboard/`
| Fichier actuel | Notes |
|----------------|--------|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
| `dossiers_management_widget.dart` | |
| `dossier_list_card.dart` | |
| `parent_managmant_widget.dart` | Typo historique `managmant`**option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
| `enfant_management_widget.dart` | |
| `assistante_maternelle_management_widget.dart` | |
| `gestionnaire_management_widget.dart` | |
| `pending_validation_widget.dart` | |
| `parent_dossier_create_modal.dart` | |
| `parent_dossier_wizard.dart` | |
| `am_dossier_create_modal.dart` | |
| `am_dossier_wizard.dart` | |
| `validation_am_wizard.dart` | |
| `validation_family_wizard.dart` | |
| `validation_dossier_modal.dart` | |
| `validation_modal_theme.dart` | |
| `validation_refus_form.dart` | |
| `validation_valider_confirm_dialog.dart` | |
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
| `relais_management_panel.dart` | |
### `widgets/admin/common/``widgets/dashboard/common/` (ou plat)
| Fichier | Notes |
|---------|--------|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
| `user_list.dart` | |
| `validation_detail_section.dart` | |
### **Ne pas** déplacer
| Fichier | Destination |
|---------|-------------|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
### Déjà fait (#155) — ne pas retraiter
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
---
## Consommateurs dimports (à mettre à jour)
### Screens
- `screens/administrateurs/admin_dashboardScreen.dart``UserManagementPanel`, `ParametresPanel`
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart``UserManagementPanel`
- `screens/administrateurs/creation/admin_create.dart``suppression_confirm_dialog`
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
### Widgets déjà en `dashboard/`
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
### Divers
- `widgets/common/identity_block.dart` (si import admin)
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
### Hors scope rename classes
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
`AdminManagementWidget` **conserve** son nom.
---
## Plan dexécution
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
2. `git mv` fichiers selon inventaire
3. Remplacer globalement
`package:p_tits_pas/widgets/admin/``package:p_tits_pas/widgets/dashboard/`
**sauf** `…/widgets/admin/admin_management_widget.dart`
4. Corriger imports relatifs cassés
5. Grep de contrôle (ci-dessous)
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
7. Merge → squash master si flux habituel
---
## Vérifs
```bash
# Plus de panels partagés sous admin (seul AdminManagement attendu)
find frontend/lib/widgets/admin -name '*.dart'
# Plus dimports panels vers lancien chemin (sauf AdminManagement)
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
# Screens OK
rg -n "widgets/admin/" frontend/lib/screens
```
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
---
## Hors scope
- Refonte UX des modales / panels (ticket dédié annoncé)
- Rename `EnfantAdminModel`
- Rename `screens/administrateurs/`
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
- Changement API / back
- #152 (`est_multiple`) — autre branche
---
## Critères dacceptation
- [ ] Inventaire déplacé selon tableau
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
- [ ] Imports screens + widgets à jour
- [ ] Build Flutter OK
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
- [ ] Aucun changement comportemental volontaire
---
## Risques / notes
- **Conflits de merge** si dautres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
- Docs darchive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
@@ -0,0 +1,14 @@
# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur)
**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164
**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`)
**Périmètre** : **front only** — pas dAPI / BDD
**Milestone** : **0.1.0**
Voir le corps du ticket #164 pour la spec complète.
## Livré
- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart``StaffUserFormModal`
- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré
- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget`
@@ -13,6 +13,10 @@ class DossierListItem {
final String? statut; final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente. /// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl; final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({ const DossierListItem({
required this.type, required this.type,
@@ -21,8 +25,32 @@ class DossierListItem {
this.emails = const [], this.emails = const [],
this.statut, this.statut,
this.photoUrl, this.photoUrl,
this.sansEnfant = false,
this.enfantsCount,
}); });
DossierListItem copyWith({
DossierListType? type,
String? numeroDossier,
String? libelle,
List<String>? emails,
String? statut,
String? photoUrl,
bool? sansEnfant,
int? enfantsCount,
}) {
return DossierListItem(
type: type ?? this.type,
numeroDossier: numeroDossier ?? this.numeroDossier,
libelle: libelle ?? this.libelle,
emails: emails ?? this.emails,
statut: statut ?? this.statut,
photoUrl: photoUrl ?? this.photoUrl,
sansEnfant: sansEnfant ?? this.sansEnfant,
enfantsCount: enfantsCount ?? this.enfantsCount,
);
}
bool get isFamille => type == DossierListType.famille; bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle; bool get isAm => type == DossierListType.assistanteMaternelle;
@@ -98,6 +126,19 @@ class DossierListItem {
} }
} }
final childIds = <String>{};
var maxCountFallback = 0;
for (final p in entry.value) {
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCountFallback) maxCountFallback = n;
}
final enfantsCount =
childIds.isNotEmpty ? childIds.length : maxCountFallback;
items.add( items.add(
DossierListItem( DossierListItem(
type: DossierListType.famille, type: DossierListType.famille,
@@ -105,6 +146,8 @@ class DossierListItem {
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails, emails: emails,
statut: _preferStatut(statuts), statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
), ),
); );
} }
+6 -5
View File
@@ -185,7 +185,6 @@ class EnfantDossier {
final String? dueDate; final String? dueDate;
final String? photoUrl; final String? photoUrl;
final bool consentPhoto; final bool consentPhoto;
final bool estMultiple;
EnfantDossier({ EnfantDossier({
required this.id, required this.id,
@@ -197,7 +196,6 @@ class EnfantDossier {
this.dueDate, this.dueDate,
this.photoUrl, this.photoUrl,
this.consentPhoto = false, this.consentPhoto = false,
this.estMultiple = false,
}); });
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
@@ -215,8 +213,13 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) { factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl']; final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto); final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier( return EnfantDossier(
id: json['id']?.toString() ?? '', id: rawId?.toString().trim() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(), birthDate: json['birth_date']?.toString(),
@@ -226,8 +229,6 @@ class EnfantDossier {
photoUrl: resolvedPhoto, photoUrl: resolvedPhoto,
consentPhoto: consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true, json['consent_photo'] == true || json['consentPhoto'] == true,
estMultiple:
json['est_multiple'] == true || json['estMultiple'] == true,
); );
} }
} }
@@ -12,7 +12,6 @@ class EnfantAdminModel {
final String status; final String status;
final String? photoUrl; final String? photoUrl;
final bool consentPhoto; final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks; final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]). /// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable; final bool? sansResponsable;
@@ -27,7 +26,6 @@ class EnfantAdminModel {
required this.status, required this.status,
this.photoUrl, this.photoUrl,
this.consentPhoto = false, this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [], this.parentLinks = const [],
this.sansResponsable, this.sansResponsable,
}); });
@@ -56,7 +54,6 @@ class EnfantAdminModel {
String? status, String? status,
String? photoUrl, String? photoUrl,
bool? consentPhoto, bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks, List<EnfantParentLink>? parentLinks,
bool? sansResponsable, bool? sansResponsable,
}) { }) {
@@ -70,7 +67,6 @@ class EnfantAdminModel {
status: status ?? this.status, status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl, photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto, consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks, parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable, sansResponsable: sansResponsable ?? this.sansResponsable,
); );
@@ -111,8 +107,6 @@ class EnfantAdminModel {
), ),
photoUrl: photoUrl, photoUrl: photoUrl,
consentPhoto: consentPhoto, consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links, parentLinks: links,
sansResponsable: sansResponsable, sansResponsable: sansResponsable,
); );
@@ -127,7 +121,6 @@ class EnfantAdminModel {
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate, if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate, if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto, 'consent_photo': consentPhoto,
'is_multiple': isMultiple,
}; };
} }
@@ -30,7 +30,6 @@ class ChildData {
String lastName; String lastName;
String dob; // Date de naissance ou prévisionnelle String dob; // Date de naissance ou prévisionnelle
bool photoConsent; bool photoConsent;
bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
@@ -40,7 +39,6 @@ class ChildData {
this.lastName = '', this.lastName = '',
this.dob = '', this.dob = '',
this.photoConsent = false, this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
required this.cardColor, // Rendre requis dans le constructeur required this.cardColor, // Rendre requis dans le constructeur
@@ -38,7 +38,6 @@ class ChildData {
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi. /// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre; String genre;
bool photoConsent; bool photoConsent;
bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web). /// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
@@ -55,7 +54,6 @@ class ChildData {
this.dob = '', this.dob = '',
this.genre = '', this.genre = '',
this.photoConsent = false, this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
this.imageBytes, this.imageBytes,
@@ -70,7 +68,6 @@ class ChildData {
String? dob, String? dob,
String? genre, String? genre,
bool? photoConsent, bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild, bool? isUnbornChild,
Object? imageFile = _unsetImage, Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes, Object? imageBytes = _unsetImageBytes,
@@ -84,7 +81,6 @@ class ChildData {
dob: dob ?? this.dob, dob: dob ?? this.dob,
genre: genre ?? this.genre, genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent, photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild, isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes: imageBytes:
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async { Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final name = widget.initialUser!.fullName.isEmpty
context: context, ? widget.initialUser!.email
builder: (ctx) { : widget.initialUser!.fullName;
return AlertDialog( final confirmed = await showSuppressionConfirmDialog(
title: const Text('Confirmer la suppression'), context,
content: Text( title: 'Supprimer l\'administrateur',
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', people: [SuppressionPersonLine.administrateur(name)],
), footnotes: const [
actions: [ 'Le compte sera définitivement supprimé.',
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
], ],
); );
},
);
if (confirmed != true) return; if (!confirmed) return;
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
@@ -1,670 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
}
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
bool _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
if (widget.initialUser != null) {
return (widget.initialUser!.role).toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
case 'assistante_maternelle':
return 'Assistante maternelle';
case 'parent':
return 'Parent';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
case 'assistante_maternelle':
return Icons.child_care_outlined;
case 'parent':
return Icons.supervisor_account_outlined;
default:
return Icons.person_outline;
}
}
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
});
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose();
}
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
// Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() {
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) {
return base;
}
return validateEmail(value, allowEmpty: true);
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
String? _validatePhone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) {
return base;
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String _toTitleCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed;
final words = trimmed.split(RegExp(r'\s+'));
final normalizedWords = words.map(_capitalizeComposedWord).toList();
return normalizedWords.join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) return word;
final lower = word.toLowerCase();
final separators = <String>{"-", "'", ""};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty
? normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim();
final initialPhone = normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged =
normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
_emailController.text.trim() == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gestionnaire supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? (widget.readOnly
? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
),
),
if (_isEditMode && !widget.readOnly)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
),
content: SizedBox(
width: 620,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
if (widget.withRelais) ...[
const SizedBox(height: 12),
_buildRelaisField(),
],
],
),
),
),
),
actions: [
if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (_canDeleteTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
),
] else ...[
OutlinedButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
),
],
],
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return EmailTextFormField(
controller: _emailController,
readOnly: widget.readOnly,
label: 'Email',
validator: widget.readOnly ? null : _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
),
validator: widget.readOnly ? null : _validatePassword,
);
}
Widget _buildTelephoneField() {
return FrenchPhoneTextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
label: 'Téléphone (ex: 06 12 34 56 78)',
validator: widget.readOnly ? null : _validatePhone,
);
}
Widget _buildRelaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Relais principal',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || widget.readOnly)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
});
},
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
);
}
}
@@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
dob: '', dob: '',
isUnbornChild: false, isUnbornChild: false,
photoConsent: false, photoConsent: false,
multipleBirth: false,
cardColor: cardColor, cardColor: cardColor,
); );
registrationData.addChild(newChild); registrationData.addChild(newChild);
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: UserManagementPanel(showAdministrateursTab: false), child: UserManagementPanel(
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
), ),
const AppFooter(), const AppFooter(),
], ],
+183 -16
View File
@@ -525,17 +525,58 @@ class UserService {
return enfant.copyWith(parentLinks: enriched); return enfant.copyWith(parentLinks: enriched);
} }
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({ static Future<EnfantAdminModel> updateEnfant({
required String enfantId, required String enfantId,
required Map<String, dynamic> body, required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async { }) async {
final response = await http.patch( final id = enfantId.trim();
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), if (id.isEmpty) {
throw Exception('Identifiant enfant manquant.');
}
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(), headers: await _headers(),
body: jsonEncode(body), body: jsonEncode(body),
); );
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
} }
final enfant = EnfantAdminModel.fromJson( final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>, jsonDecode(response.body) as Map<String, dynamic>,
@@ -605,14 +646,93 @@ class UserService {
return enrichEnfantParentNames(enfant); return enrichEnfantParentNames(enfant);
} }
static Future<void> deleteEnfant(String enfantId) async { /// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteEnfant(
String enfantId, {
bool deleteDossier = false,
}) async {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
).replace(
queryParameters: {
'deleteDossier': deleteDossier ? 'true' : 'false',
},
);
final response = await http.delete(uri, headers: await _headers());
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
);
}
return _parseSuppressionBody(response.body);
}
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteDossier(
String numeroDossier,
) async {
final num = numeroDossier.trim();
if (num.isEmpty) {
throw Exception('Numéro de dossier manquant.');
}
final encoded = Uri.encodeComponent(num);
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant')); throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
);
} }
return _parseSuppressionBody(response.body);
}
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
.replace(
queryParameters: (q != null && q.trim().isNotEmpty)
? {'q': q.trim()}
: null,
);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
for (final row in rows) {
final num = (row['numero_dossier'] ?? '').toString().trim();
if (num.isEmpty) continue;
final type = (row['type'] ?? '').toString().toLowerCase();
if (type != 'famille') continue;
out[num] = row['sans_enfant'] == true;
}
return out;
}
static Map<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
} }
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
@@ -645,6 +765,57 @@ class UserService {
return fallback; return fallback;
} }
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129). /// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet. /// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
@@ -1132,22 +1303,18 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<void> deleteUser(String userId) async { /// DELETE /users/:id — cascades métier #159 / #160.
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body); throw Exception(
if (decoded is Map<String, dynamic>) { _extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
final message = decoded['message']; );
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
} }
return _parseSuppressionBody(response.body);
} }
} }
@@ -166,7 +166,6 @@ class ParentRegistrationPayload {
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) { static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent, 'consent_photo': c.photoConsent,
}; };
-1
View File
@@ -44,7 +44,6 @@ class RepriseMapper {
dob: dob, dob: dob,
genre: e.gender ?? '', genre: e.gender ?? '',
photoConsent: e.consentPhoto, photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn, isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length], cardColor: _childCardColors[index % _childCardColors.length],
repriseChildId: e.id, repriseChildId: e.id,
@@ -0,0 +1,59 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'gestionnaire' ||
r == 'administrateur' ||
r == 'super_admin';
}
bool canDeleteGestionnaire(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
bool canDeleteAdministrateur({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
final me = (currentRole ?? '').trim().toLowerCase();
if (me != 'administrateur' && me != 'super_admin') return false;
if (targetUserId.trim().isEmpty) return false;
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return false;
}
final target = targetRole.trim().toLowerCase();
if (target == 'super_admin') return false;
if (adminCount <= 1 && me != 'super_admin') return false;
return true;
}
String? adminDeleteBlockedReason({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return 'Vous ne pouvez pas supprimer votre propre compte.';
}
if (targetRole.trim().toLowerCase() == 'super_admin') {
return 'Le super administrateur ne peut pas être supprimé.';
}
if (adminCount <= 1 &&
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
}
return null;
}
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget { class AdminManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
String? _currentUserRole; String? _currentUserRole;
String? _currentUserId;
@override @override
void initState() { void initState() {
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserRole = (cached.role).toLowerCase(); _currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
}); });
return; return;
} }
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserRole = (refreshed.role).toLowerCase(); _currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
}); });
} }
@@ -79,13 +84,23 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin'; return _currentUserRole == 'super_admin';
} }
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async { Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>( final changed = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AdminUserFormDialog( return StaffUserFormModal(
initialUser: user, initialUser: user,
adminMode: true, adminMode: true,
withRelais: false, withRelais: false,
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _confirmDelete(AppUser user) async {
final blocked = adminDeleteBlockedReason(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: user.id,
targetRole: user.role,
adminCount: _admins.length,
);
if (blocked != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(blocked)),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final isLast = _admins.length <= 1;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: [
if (isLast)
'Attention : cest le dernier administrateur.',
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg =
(result['message'] ?? 'Administrateur supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAdmins();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -117,7 +182,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index]; final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user); final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
return AdminUserCard( final canDelete = _canDeleteAdmin(user);
return UserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: isSuperAdmin fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined ? Icons.verified_user_outlined
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },
), ),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
], ],
); );
}, },
@@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.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_enfant_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;
});
}
}
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 ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return AdminEnfantUserCard.fromEnfant(
enfant,
onCardTap: () => _openEnfant(enfant),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
],
);
},
);
}
}
@@ -3,7 +3,7 @@ import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
/// Valeurs affichées dans un [IdentityBlock] en lecture seule. /// Valeurs affichées dans un [IdentityBlock] en lecture seule.
class IdentityValues { class IdentityValues {
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). /// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AdminAmChildrenCapacityGrid extends StatelessWidget { class AmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4; static const int _gridSlots = 4;
static const double _slotHeight = 44; static const double _slotHeight = 44;
static const double _gridPadding = 10; static const double _gridPadding = 10;
@@ -23,7 +23,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty; final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({ const AmChildrenCapacityGrid({
super.key, super.key,
required this.children, required this.children,
required this.capacity, required this.capacity,
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal]. /// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
class AmDossierCreateModal extends StatefulWidget { class AmDossierCreateModal extends StatefulWidget {
@@ -14,18 +14,18 @@ import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum AmDossierWizardMode { review, create } enum AmDossierWizardMode { review, create, edit }
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). /// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135).
class AmDossierWizard extends StatefulWidget { class AmDossierWizard extends StatefulWidget {
final AmDossierWizardMode mode; final AmDossierWizardMode mode;
final DossierAM? dossier; final DossierAM? dossier;
@@ -74,7 +74,25 @@ class AmDossierWizard extends StatefulWidget {
); );
} }
factory AmDossierWizard.edit({
Key? key,
required DossierAM dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == AmDossierWizardMode.create; bool get isCreate => mode == AmDossierWizardMode.create;
bool get isEdit => mode == AmDossierWizardMode.edit;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight => static double get shellBodyHeight =>
@@ -118,9 +136,11 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
String? _photoFilename; String? _photoFilename;
bool get _isCreate => widget.isCreate; bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierAM get _dossier => widget.dossier!; DossierAM get _dossier => widget.dossier!;
bool get _isEnAttente => bool get _isEnAttente =>
!_isCreate && _dossier.user.statut == 'en_attente'; !_isCreate && !_isEdit && _dossier.user.statut == 'en_attente';
static String _v(String? s) => static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : ''; (s != null && s.trim().isNotEmpty) ? s.trim() : '';
@@ -151,9 +171,33 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
_capaciteCtrl = TextEditingController(text: '1'); _capaciteCtrl = TextEditingController(text: '1');
_placesCtrl = TextEditingController(text: '1'); _placesCtrl = TextEditingController(text: '1');
_presentationCtrl = TextEditingController(); _presentationCtrl = TextEditingController();
if (_isEdit) {
_prefillFromDossier();
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
} }
void _prefillFromDossier() {
final u = _dossier.user;
_nomCtrl.text = (u.nom ?? '').trim();
_prenomCtrl.text = (u.prenom ?? '').trim();
_telCtrl.text = (u.telephone ?? '').trim();
_emailCtrl.text = u.email.trim();
_adresseCtrl.text = (u.adresse ?? '').trim();
_cpCtrl.text = (u.codePostal ?? '').trim();
_villeCtrl.text = (u.ville ?? '').trim();
_nirCtrl.text = (_dossier.nir ?? '').trim();
_dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance);
_lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim();
final pays = (u.lieuNaissancePays ?? '').trim();
_lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays;
_agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim();
_dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement);
_capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}';
_placesCtrl.text = '${_dossier.placesDisponibles ?? 0}';
_presentationCtrl.text = (_dossier.presentation ?? '').trim();
}
@override @override
void dispose() { void dispose() {
_nomCtrl.dispose(); _nomCtrl.dispose();
@@ -177,32 +221,32 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
List<AdminDetailField> _photoProFields(DossierAM d) { List<DetailField> _photoProFields(DossierAM d) {
final u = d.user; final u = d.user;
return [ return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), DetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField( DetailField(
label: 'Date de naissance', label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance), value: formatIsoDateFr(u.dateNaissance),
), ),
AdminDetailField( DetailField(
label: 'Ville de naissance', label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille), value: _v(u.lieuNaissanceVille),
), ),
AdminDetailField( DetailField(
label: 'Pays de naissance', label: 'Pays de naissance',
value: _v(u.lieuNaissancePays), value: _v(u.lieuNaissancePays),
), ),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), DetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField( DetailField(
label: 'Date dagrément', label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement), value: formatIsoDateFr(d.dateAgrement),
), ),
AdminDetailField( DetailField(
label: 'Capacité max (enfants)', label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '', value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
), ),
AdminDetailField( DetailField(
label: 'Places disponibles', label: 'Places disponibles',
value: d.placesDisponibles != null value: d.placesDisponibles != null
? d.placesDisponibles.toString() ? d.placesDisponibles.toString()
@@ -373,8 +417,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
return null; return null;
} }
String? _validateStep1() { String? _validateStep1({required bool requirePhoto}) {
if (_photoBytes == null || _photoBytes!.isEmpty) { if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) {
return 'Une photo de profil est requise.'; return 'Une photo de profil est requise.';
} }
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
@@ -407,12 +451,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
String? _validateCurrentStep() { String? _validateCurrentStep() {
if (!_isCreate) return null; if (!_isEditable) return null;
switch (_step) { switch (_step) {
case 0: case 0:
return _validateStep0(); return _validateStep0();
case 1: case 1:
return _validateStep1(); return _validateStep1(requirePhoto: _isCreate);
default: default:
return null; return null;
} }
@@ -511,7 +555,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
); );
return; return;
} }
final err1 = _validateStep1(); final err1 = _validateStep1(requirePhoto: true);
if (err1 != null) { if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
@@ -554,6 +598,94 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
} }
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateStep0();
if (err0 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
);
return;
}
final err1 = _validateStep1(requirePhoto: false);
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
);
return;
}
final amId = _dossier.user.id.trim();
if (amId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Identifiant AM manquant.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body: 'Enregistrer les modifications de la fiche assistante maternelle ?',
);
if (!mounted || !ok) return;
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
final capa = int.tryParse(_capaciteCtrl.text.trim());
final places = int.tryParse(_placesCtrl.text.trim());
final biography = _presentationCtrl.text.trim();
setState(() => _submitting = true);
try {
await UserService.updateAmFiche(
amUserId: amId,
body: {
'nom': formatPersonNameCase(_nomCtrl.text),
'prenom': formatPersonNameCase(_prenomCtrl.text),
'email': normalizeEmailText(_emailCtrl.text),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': formatPersonNameCase(_villeCtrl.text),
'code_postal': _cpCtrl.text.trim(),
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (birthIso != null) 'date_naissance': birthIso,
'lieu_naissance_ville':
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
if (agrementIso != null) 'agreement_date': agrementIso,
if (capa != null) 'max_children': capa,
if (places != null) 'places_available': places,
'biography': biography,
},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fiche AM enregistrée.'),
duration: Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_showRefusForm) { if (_showRefusForm) {
@@ -587,7 +719,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
Widget _buildStep0() { Widget _buildStep0() {
if (_isCreate) { if (_isEditable) {
return IdentityBlock.editable( return IdentityBlock.editable(
title: 'Identité et coordonnées', title: 'Identité et coordonnées',
nomController: _nomCtrl, nomController: _nomCtrl,
@@ -609,18 +741,18 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
Widget _buildStep1() { Widget _buildStep1() {
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile // Modale calée sur les TF ; photo étirée sur toute la hauteur utile
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus). // (largeur = [AmPhotoFrame.columnWidthForHeight], cadre inclus).
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight.clamp(0.0, double.infinity); final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH) var photoW = AmPhotoFrame.columnWidthForHeight(maxRowH)
.clamp(_photoColumnMinWidth, 360.0); .clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW; if (photoW > maxPhotoW) photoW = maxPhotoW;
final form = _isCreate final form = _isEditable
? _buildCreateProFields() ? _buildCreateProFields()
: ValidationDetailSection( : ValidationDetailSection(
title: 'Dossier professionnel', title: 'Dossier professionnel',
@@ -628,20 +760,25 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
rowLayout: _photoProRowLayout, rowLayout: _photoProRowLayout,
); );
return Row( final Widget photo;
crossAxisAlignment: CrossAxisAlignment.stretch, if (_isCreate) {
children: [ photo = AmPhotoFrame(
SizedBox(
width: photoW,
child: _isCreate
? AdminAmPhotoFrame(
imageBytes: _photoBytes, imageBytes: _photoBytes,
onTap: _pickPhoto, onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null, onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo', emptyLabel: 'Ajouter une photo',
) );
: _buildPhotoSectionReview(_dossier.user), } else if (_isEdit) {
), // Édition : photo existante en lecture ; remplacement hors scope PATCH fiche.
photo = _buildPhotoSectionReview(_dossier.user);
} else {
photo = _buildPhotoSectionReview(_dossier.user);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: photoW, child: photo),
const SizedBox(width: _photoProGap), const SizedBox(width: _photoProGap),
Expanded( Expanded(
child: Align( child: Align(
@@ -721,7 +858,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
Widget _buildStep2() { Widget _buildStep2() {
if (_isCreate) { if (_isEditable) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -832,6 +969,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
onPressed: _submitting ? null : _createAndValidate, onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
), ),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente) ...[ ] else if (_isEnAttente) ...[
OutlinedButton( OutlinedButton(
onPressed: _submitting ? null : _refuser, onPressed: _submitting ? null : _refuser,
@@ -7,31 +7,31 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.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_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/dashboard/am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. /// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget { class AmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante; final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved; final VoidCallback? onSaved;
const AdminAmEditModal({ const AmEditModal({
super.key, super.key,
required this.assistante, required this.assistante,
this.onSaved, this.onSaved,
}); });
@override @override
State<AdminAmEditModal> createState() => _AdminAmEditModalState(); State<AmEditModal> createState() => _AmEditModalState();
} }
class _AdminAmEditModalState extends State<AdminAmEditModal> class _AmEditModalState extends State<AmEditModal>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final TabController _tabCtrl; late final TabController _tabCtrl;
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
static const double _proTabHeight = 300; static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2]; static const List<int> _photoProRowLayout = [2, 2, 2];
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
double _childrenTabHeight() {
const capacityFields = 72.0;
const titleSection = 40.0;
const inconsistencyExtra = 46.0;
var h = capacityFields +
titleSection +
AdminAmChildrenCapacityGrid.fixedHeight;
if (_placesInconsistent()) h += inconsistencyExtra;
return h + 4;
}
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return _childrenTabHeight();
default:
return 292;
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -427,7 +404,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminChildDetailModal( builder: (ctx) => ChildDetailModal(
enfant: enfant, enfant: enfant,
onSaved: _refreshChildrenDetails, onSaved: _refreshChildrenDetails,
), ),
@@ -473,7 +450,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (_capacityFull || !mounted) return; if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show( final selected = await SelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant', title: 'Rattacher un enfant',
@@ -541,8 +518,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _identityTab() { Widget _identityTab() {
return SingleChildScrollView( return IdentityBlock.editable(
child: IdentityBlock.editable(
title: 'Identité et coordonnées', title: 'Identité et coordonnées',
nomController: _nomCtrl, nomController: _nomCtrl,
prenomController: _prenomCtrl, prenomController: _prenomCtrl,
@@ -551,7 +527,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
adresseController: _adresseCtrl, adresseController: _adresseCtrl,
codePostalController: _cpCtrl, codePostalController: _cpCtrl,
villeController: _villeCtrl, villeController: _villeCtrl,
),
); );
} }
@@ -610,9 +585,11 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; // Hauteur bornée : longlet pro a une hauteur fixe (photo ID).
final maxRowH =
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
final bodyH = maxRowH; final bodyH = maxRowH;
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; final idealPhotoW = bodyH * AmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
@@ -623,7 +600,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
children: [ children: [
SizedBox( SizedBox(
width: photoW, width: photoW,
child: AdminAmPhotoFrame( child: AmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl, photoUrl: widget.assistante.user.photoUrl,
), ),
), ),
@@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
); );
} }
/// Corps de longlet actif — hauteur naturelle (pas de TabBarView).
Widget _buildActiveTabBody() {
switch (_tabCtrl.index) {
case 1:
return SizedBox(height: _proTabHeight, child: _proTab());
case 2:
return _childrenTab();
default:
return _identityTab();
}
}
Widget _childrenCapacityFields() { Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent(); final inconsistent = _placesInconsistent();
return Column( return Column(
@@ -739,7 +728,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
AdminAmChildrenCapacityGrid( AmChildrenCapacityGrid(
children: _children, children: _children,
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
@@ -831,7 +820,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 160, width: 160,
child: AdminStatusCapsule( child: StatusCapsule(
statut: _statut, statut: _statut,
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_statut = v; _statut = v;
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox( child: AnimatedSize(
height: _tabViewHeight(_tabCtrl.index), duration: const Duration(milliseconds: 180),
child: TabBarView( curve: Curves.easeInOut,
controller: _tabCtrl, alignment: Alignment.topCenter,
children: [ child: KeyedSubtree(
_identityTab(), key: ValueKey<int>(_tabCtrl.index),
_proTab(), child: _buildActiveTabBody(),
_childrenTab(),
],
), ),
), ),
), ),
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. /// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget { class AmPhotoFrame extends StatelessWidget {
final String? photoUrl; final String? photoUrl;
final Uint8List? imageBytes; final Uint8List? imageBytes;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -14,7 +14,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
static const double idPhotoAspectRatio = 35 / 45; static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({ const AmPhotoFrame({
super.key, super.key,
this.photoUrl, this.photoUrl,
this.imageBytes, this.imageBytes,
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget { class AssistanteMaternelleManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AssistanteMaternelleModel> _assistantes = []; List<AssistanteMaternelleModel> _assistantes = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadAssistantes(); _loadAssistantes();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAssistantes() async { Future<void> _loadAssistantes() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState
} }
} }
Future<void> _confirmDelete(AssistanteMaternelleModel am) async {
final num = (am.user.numeroDossier ?? '').trim();
final name = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'assistante maternelle',
subtitle: num.isEmpty ? null : 'Dossier $num',
people: [SuppressionPersonLine.am(name)],
footnotes: const [
'Le compte et le dossier AM seront supprimés.',
'Les enfants accueillis ne seront pas supprimés '
'(placements clos).',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(am.user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'AM supprimée.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAssistantes();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -78,7 +130,7 @@ class _AssistanteMaternelleManagementWidgetState
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assistante = filteredAssistantes[index]; final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante); final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard( return UserCard(
title: assistante.user.fullName, title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl, avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face, fallbackIcon: Icons.face,
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
_openAssistanteDetails(assistante); _openAssistanteDetails(assistante);
}, },
), ),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(assistante),
),
], ],
); );
}, },
@@ -105,7 +161,7 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) { void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AdminAmEditModal( builder: (context) => AmEditModal(
assistante: assistante, assistante: assistante,
onSaved: _loadAssistantes, onSaved: _loadAssistantes,
), ),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.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/dossier_list_item.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
@@ -9,23 +10,25 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (#138) ou création (#132). /// Fiche enfant consultation / édition (#138) ou création (#132).
class AdminChildDetailModal extends StatefulWidget { class ChildDetailModal extends StatefulWidget {
final EnfantAdminModel? enfant; final EnfantAdminModel? enfant;
final VoidCallback? onSaved; final VoidCallback? onSaved;
final VoidCallback? onDeleted; final VoidCallback? onDeleted;
final bool isCreating; final bool isCreating;
const AdminChildDetailModal({ const ChildDetailModal({
super.key, super.key,
required EnfantAdminModel this.enfant, required EnfantAdminModel this.enfant,
this.onSaved, this.onSaved,
@@ -33,7 +36,7 @@ class AdminChildDetailModal extends StatefulWidget {
}) : isCreating = false; }) : isCreating = false;
/// Création depuis l'onglet Enfants (#132). /// Création depuis l'onglet Enfants (#132).
const AdminChildDetailModal.create({ const ChildDetailModal.create({
super.key, super.key,
this.onSaved, this.onSaved,
}) : enfant = null, }) : enfant = null,
@@ -41,10 +44,10 @@ class AdminChildDetailModal extends StatefulWidget {
isCreating = true; isCreating = true;
@override @override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState(); State<ChildDetailModal> createState() => _ChildDetailModalState();
} }
class _AdminChildDetailModalState extends State<AdminChildDetailModal> { class _ChildDetailModalState extends State<ChildDetailModal> {
late final TextEditingController _prenomCtrl; late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl; late final TextEditingController _birthCtrl;
@@ -52,7 +55,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late String _status; late String _status;
late String? _gender; late String? _gender;
late bool _consentPhoto; late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false; bool _dirty = false;
bool _saving = false; bool _saving = false;
bool _deleting = false; bool _deleting = false;
@@ -63,7 +65,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
String? _baselineAmUserId; String? _baselineAmUserId;
/// Famille choisie en mode création (#132). /// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily; FamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157). /// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks; List<EnfantParentLink>? _localParentLinks;
@@ -97,8 +99,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; _isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete => bool get _canDelete =>
!widget.isCreating && !widget.isCreating && canDeleteMetier(_currentUserRole);
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting; bool get _busy => _saving || _deleting;
@@ -140,7 +141,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
} }
_consentPhoto = e?.consentPhoto ?? false; _consentPhoto = e?.consentPhoto ?? false;
_isMultiple = e?.isMultiple ?? false;
for (final c in [_prenomCtrl, _nomCtrl]) { for (final c in [_prenomCtrl, _nomCtrl]) {
c.addListener(_onNameChanged); c.addListener(_onNameChanged);
} }
@@ -283,7 +283,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminParentEditModal( builder: (ctx) => ParentEditModal(
parent: parent, parent: parent,
onSaved: () => widget.onSaved?.call(), onSaved: () => widget.onSaved?.call(),
), ),
@@ -381,7 +381,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
else if (_dateToIso(_birthCtrl.text) != null) else if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text), 'birth_date': _dateToIso(_birthCtrl.text),
'consent_photo': _consentPhoto, 'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
}, },
); );
if (!mounted) return; if (!mounted) return;
@@ -458,7 +457,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
'birth_date': _dateToIso(_birthCtrl.text), 'birth_date': _dateToIso(_birthCtrl.text),
}, },
'consent_photo': _consentPhoto, 'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
}, },
); );
if (!mounted) return; if (!mounted) return;
@@ -506,37 +504,99 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!_canDelete || _deleting) return; if (!_canDelete || _deleting) return;
final name = _headerTitle(); final name = _headerTitle();
final confirmed = await showDialog<bool>( final enfant = widget.enfant;
context: context, if (enfant == null) return;
builder: (ctx) => AlertDialog(
title: const Text('Supprimer l\'enfant'), bool deleteDossierFlag = false;
content: Text( String? numero;
'Supprimer définitivement la fiche de $name ?\n' String familleLabel = '';
'Cette action est irréversible.', bool isLast = false;
),
actions: [ String? amLabel;
TextButton( final linked = _linkedAm;
onPressed: () => Navigator.pop(ctx, false), if (linked != null) {
child: const Text('Annuler'), final label = formatDossierPersonLabel(
), nom: linked.user.nom,
ElevatedButton( prenom: linked.user.prenom,
onPressed: () => Navigator.pop(ctx, true), email: linked.user.email,
style: ElevatedButton.styleFrom( );
backgroundColor: Colors.red.shade700, if (label.isNotEmpty) amLabel = label;
foregroundColor: Colors.white, } else {
), try {
child: const Text('Supprimer'), final am = await UserService.findAmForEnfant(enfant.id);
), if (am != null) {
], final label = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
if (label.isNotEmpty) amLabel = label;
}
} catch (_) {}
}
final parentId = enfant.parentLinks
.map((l) => l.parentId.trim())
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
if (parentId.isNotEmpty) {
try {
final parent = await UserService.getParent(parentId);
final num = (parent.user.numeroDossier ?? '').trim();
numero = num;
familleLabel = parent.user.fullName.isNotEmpty
? parent.user.fullName
: parent.user.email;
if (num.isNotEmpty) {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
isLast = dossier.asFamily.enfants.length <= 1;
final names = dossier.asFamily.parents
.map((p) => formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
))
.where((s) => s.isNotEmpty)
.join(' - ');
if (names.isNotEmpty) familleLabel = names;
}
}
} catch (_) {}
}
if (!mounted) return;
if (isLast && (numero ?? '').isNotEmpty) {
final choice = await showDernierEnfantSuppressionDialog(
context,
enfantName: name,
familleLabel: familleLabel,
numeroDossier: numero!,
amLabel: amLabel,
);
if (choice == null || !mounted) return;
deleteDossierFlag =
choice == DernierEnfantSuppressionChoice.dossierAussi;
} else {
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'enfant',
subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero',
people: [SuppressionPersonLine.enfant(name)],
footnotes: enfantSuppressionFootnotes(
numeroDossier: numero,
familleLabel: familleLabel,
amLabel: amLabel,
), ),
); );
if (confirmed != true || !mounted) return; if (!confirmed || !mounted) return;
}
setState(() => _deleting = true); setState(() => _deleting = true);
try { try {
final id = widget.enfant?.id; final id = widget.enfant?.id;
if (id == null || id.isEmpty) return; if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id); await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag);
if (!mounted) return; if (!mounted) return;
widget.onDeleted?.call(); widget.onDeleted?.call();
widget.onSaved?.call(); widget.onSaved?.call();
@@ -548,7 +608,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!mounted) return; if (!mounted) return;
setState(() => _deleting = false); setState(() => _deleting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
); );
} }
} }
@@ -558,7 +624,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (am == null) return; if (am == null) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminAmEditModal( builder: (ctx) => AmEditModal(
assistante: am, assistante: am,
onSaved: () async { onSaved: () async {
await _reloadPlacementFromServer(); await _reloadPlacementFromServer();
@@ -610,7 +676,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
if (!mounted) return; if (!mounted) return;
final selected = await AdminSelectAmModal.show( final selected = await SelectAmModal.show(
context, context,
excludeIds: { excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id, if (_linkedAm != null) _linkedAm!.user.id,
@@ -920,7 +986,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; final maxRowH = c.maxHeight;
final idealPhotoW = final idealPhotoW =
maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; maxRowH * AmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
@@ -935,7 +1001,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: AdminAmPhotoFrame( child: AmPhotoFrame(
photoUrl: widget.isCreating photoUrl: widget.isCreating
? null ? null
: widget.enfant?.photoUrl, : widget.enfant?.photoUrl,
@@ -1263,7 +1329,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
Future<void> _pickFamily() async { Future<void> _pickFamily() async {
if (_busy) return; if (_busy) return;
final selected = await AdminSelectFamilleModal.show( final selected = await SelectFamilleModal.show(
context, context,
title: 'Choisir une famille', title: 'Choisir une famille',
); );
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). /// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget { class ChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children; final List<ParentChildSummary> children;
final ScrollController scrollController; final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
@@ -14,7 +14,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
static const double _itemHeight = 58; static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8; static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({ const ChildrenAffiliationPanel({
super.key, super.key,
required this.children, required this.children,
required this.scrollController, required this.scrollController,
@@ -62,7 +62,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
itemCount: children.length, itemCount: children.length,
itemBuilder: (_, i) { itemBuilder: (_, i) {
final c = children[i]; final c = children[i];
return AdminEnfantUserCard.fromSummary( return EnfantUserCard.fromSummary(
c, c,
onCardTap: () => onOpen(c), onCardTap: () => onOpen(c),
actions: [ actions: [
@@ -0,0 +1,467 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
/// Choix pour le dernier enfant dun dossier famille (#160).
enum DernierEnfantSuppressionChoice {
enfantSeul,
dossierAussi,
}
/// Ligne dimpact (une personne / une fiche).
class SuppressionPersonLine {
final String label;
final IconData icon;
final String? role;
const SuppressionPersonLine({
required this.label,
this.icon = Icons.person_outline,
this.role,
});
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
label: label,
icon: Icons.supervisor_account_outlined,
role: 'Parent',
);
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
label: label,
icon: Icons.child_care_outlined,
role: 'Enfant',
);
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
label: label,
icon: Icons.face,
role: 'AM',
);
factory SuppressionPersonLine.gestionnaire(String label) =>
SuppressionPersonLine(
label: label,
icon: Icons.assignment_ind_outlined,
role: 'Gestionnaire',
);
factory SuppressionPersonLine.administrateur(String label) =>
SuppressionPersonLine(
label: label,
icon: Icons.manage_accounts_outlined,
role: 'Admin',
);
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
label: label,
icon: Icons.apartment_outlined,
role: 'Relais',
);
}
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
class SuppressionConfirmDialog extends StatelessWidget {
final String title;
final String? subtitle;
final String? message;
final List<SuppressionPersonLine> people;
final List<String> footnotes;
final List<Widget> actions;
const SuppressionConfirmDialog({
super.key,
required this.title,
this.subtitle,
this.message,
this.people = const [],
this.footnotes = const [],
required this.actions,
});
/// Variante oui/non standard (Annuler / Supprimer).
static SuppressionConfirmDialog yesNo({
required String title,
String? subtitle,
String? message,
List<SuppressionPersonLine> people = const [],
List<String> footnotes = const [],
String confirmLabel = 'Supprimer',
required VoidCallback onCancel,
required VoidCallback onConfirm,
}) {
return SuppressionConfirmDialog(
title: title,
subtitle: subtitle,
message: message,
people: people,
footnotes: footnotes,
actions: [
TextButton(onPressed: onCancel, child: const Text('Annuler')),
FilledButton(
onPressed: onConfirm,
style: _dangerButtonStyle,
child: Text(confirmLabel),
),
],
);
}
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
backgroundColor: const Color(0xFFF7F2FB),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
title: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.delete_outline,
color: Colors.red.shade700,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF2F2F2F),
fontSize: 20,
),
),
if ((subtitle ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!.trim(),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF6D4EA1),
fontWeight: FontWeight.w600,
),
),
],
],
),
),
],
),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if ((message ?? '').trim().isNotEmpty) ...[
Text(
message!.trim(),
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.black87,
height: 1.35,
),
),
if (people.isNotEmpty || footnotes.isNotEmpty)
const SizedBox(height: 12),
],
if (people.isNotEmpty) ...[
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE5D8F2)),
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
for (var i = 0; i < people.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 44,
endIndent: 12,
color: Colors.grey.shade200,
),
_SuppressionPersonRow(line: people[i]),
],
],
),
),
if (footnotes.isNotEmpty) const SizedBox(height: 12),
],
for (final note in footnotes)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
note,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.black54,
height: 1.35,
),
),
),
],
),
),
],
),
),
actions: actions,
);
}
}
class _SuppressionPersonRow extends StatelessWidget {
final SuppressionPersonLine line;
const _SuppressionPersonRow({required this.line});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
const SizedBox(width: 10),
Expanded(
child: Text(
line.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Color(0xFF2F2F2F),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if ((line.role ?? '').trim().isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFEDE5FA),
borderRadius: BorderRadius.circular(10),
),
child: Text(
line.role!.trim(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF6D4EA1),
),
),
),
],
],
),
);
}
}
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
Future<bool> showSuppressionConfirmDialog(
BuildContext context, {
required String title,
String? subtitle,
String? message,
List<SuppressionPersonLine> people = const [],
List<String> footnotes = const [],
String confirmLabel = 'Supprimer',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => SuppressionConfirmDialog.yesNo(
title: title,
subtitle: subtitle,
message: message,
people: people,
footnotes: footnotes,
confirmLabel: confirmLabel,
onCancel: () => Navigator.of(ctx).pop(false),
onConfirm: () => Navigator.of(ctx).pop(true),
),
);
return result == true;
}
/// Confirmation delete dossier famille / AM avec liste nominative.
Future<bool> showDossierSuppressionConfirmDialog(
BuildContext context, {
required String numeroDossier,
required bool isFamille,
required List<SuppressionPersonLine> people,
String? fallbackSummary,
}) {
final num = numeroDossier.trim();
if (isFamille) {
return showSuppressionConfirmDialog(
context,
title: 'Supprimer le dossier',
subtitle: 'Dossier famille $num',
people: people,
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
? [fallbackSummary!]
: const [
'Tous les comptes et fiches listés seront définitivement '
'supprimés.',
'Les placements AM des enfants seront clos.',
],
);
}
return showSuppressionConfirmDialog(
context,
title: 'Supprimer le dossier',
subtitle: 'Dossier AM $num',
people: people,
footnotes: const [
'Le compte et le dossier AM seront supprimés.',
'Les enfants accueillis ne seront pas supprimés '
'(placements clos).',
],
);
}
/// Dialog dernier enfant : deux actions métier.
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
BuildContext context, {
required String enfantName,
required String familleLabel,
required String numeroDossier,
String? amLabel,
}) {
final am = (amLabel ?? '').trim();
return showDialog<DernierEnfantSuppressionChoice>(
context: context,
builder: (ctx) => SuppressionConfirmDialog(
title: 'Dernier enfant du dossier',
subtitle: 'Dossier $numeroDossier'
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
people: [SuppressionPersonLine.enfant(enfantName)],
footnotes: [
'« Enfant seulement » : le dossier reste sans enfant.',
'« Dossier aussi » : parents et dossier sont également '
'supprimés.',
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
],
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
OutlinedButton(
onPressed: () => Navigator.of(ctx).pop(
DernierEnfantSuppressionChoice.enfantSeul,
),
child: const Text('Enfant seulement'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(
DernierEnfantSuppressionChoice.dossierAussi,
),
style: SuppressionConfirmDialog._dangerButtonStyle,
child: const Text('Dossier aussi'),
),
],
),
);
}
/// Notes dimpact pour suppression dun enfant (AM optionnelle).
List<String> enfantSuppressionFootnotes({
required String? numeroDossier,
required String familleLabel,
String? amLabel,
}) {
final notes = <String>[];
final num = (numeroDossier ?? '').trim();
final famille = familleLabel.trim();
final am = (amLabel ?? '').trim();
if (num.isEmpty) {
notes.add('Supprimer définitivement cette fiche enfant.');
} else {
notes.add(
'Lenfant sera retiré du dossier de '
'${famille.isEmpty ? 'la famille' : famille}.',
);
}
if (am.isNotEmpty) {
notes.add('Le placement chez $am sera clos.');
}
return notes;
}
/// Bouton poubelle compact pour les cartes liste.
Widget suppressionIconButton({
required VoidCallback? onPressed,
String tooltip = 'Supprimer',
}) {
return IconButton(
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
tooltip: tooltip,
onPressed: onPressed,
);
}
/// Construit les lignes parents / enfants depuis un dossier unifié.
List<SuppressionPersonLine> suppressionPeopleFromDossier({
required bool isFamille,
required List<({String nom, String prenom, String email})> parents,
required List<({String nom, String prenom})> enfants,
String? amName,
}) {
final lines = <SuppressionPersonLine>[];
if (isFamille) {
for (final p in parents) {
final label = formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
);
if (label.isEmpty) continue;
lines.add(SuppressionPersonLine.parent(label));
}
for (final e in enfants) {
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
if (label.isEmpty) continue;
lines.add(SuppressionPersonLine.enfant(label));
}
} else if ((amName ?? '').trim().isNotEmpty) {
lines.add(SuppressionPersonLine.am(amName!.trim()));
}
return lines;
}
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart'; import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart';
class UserList extends StatelessWidget { class UserList extends StatelessWidget {
final bool isLoading; final bool isLoading;
@@ -28,7 +28,7 @@ class UserList extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
AdminListState( UserListState(
isLoading: isLoading, isLoading: isLoading,
error: error, error: error,
isEmpty: isEmpty, isEmpty: isEmpty,
@@ -4,7 +4,7 @@ import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**. /// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
class ValidationFormMetrics { class ValidationFormMetrics {
@@ -60,7 +60,7 @@ class ValidationFormMetrics {
class ValidationDetailSection extends StatelessWidget { class ValidationDetailSection extends StatelessWidget {
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM). /// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
final String? title; final String? title;
final List<AdminDetailField> fields; final List<DetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout; final List<int>? rowLayout;
@@ -1,23 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AdminDetailField { class DetailField {
final String label; final String label;
final String value; final String value;
const AdminDetailField({ const DetailField({
required this.label, required this.label,
required this.value, required this.value,
}); });
} }
class AdminDetailModal extends StatelessWidget { class DetailModal extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
final List<AdminDetailField> fields; final List<DetailField> fields;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
const AdminDetailModal({ const DetailModal({
super.key, super.key,
required this.title, required this.title,
this.subtitle, this.subtitle,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône. /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône.
class DossierListCard extends StatelessWidget { class DossierListCard extends StatelessWidget {
@@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget {
final VoidCallback onOpen; final VoidCallback onOpen;
/// Photo AM (si absente → icône fallback). /// Photo AM (si absente → icône fallback).
final String? photoUrl; final String? photoUrl;
final VoidCallback? onDelete;
/// Warning vigilance (ex. dossier sans enfant #160).
final String? vigilanceTooltip;
/// Nombre denfants (famille) — affiché à côté des noms.
final int? enfantsCount;
final bool sansEnfant;
/// Lavande — Famille / Parents. /// Lavande — Famille / Parents.
static const Color familleAccent = Color(0xFFB289C9); static const Color familleAccent = Color(0xFFB289C9);
@@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget {
required this.isFamille, required this.isFamille,
required this.onOpen, required this.onOpen,
this.photoUrl, this.photoUrl,
this.onDelete,
this.vigilanceTooltip,
this.enfantsCount,
this.sansEnfant = false,
}); });
@override @override
@@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget {
final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim(); final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim();
final names = namesLine.trim(); final names = namesLine.trim();
final avatar = (photoUrl ?? '').trim(); final avatar = (photoUrl ?? '').trim();
final emptyKids = isFamille && (sansEnfant || enfantsCount == 0);
final count = enfantsCount;
final countLabel = (!isFamille || count == null)
? null
: (count <= 1 ? '$count enfant' : '$count enfants');
return AdminUserCard( final subtitle = <String>[
if (names.isNotEmpty) names,
if (emptyKids) 'Sans enfant',
if (!emptyKids && countLabel != null) countLabel,
];
return UserCard(
title: num, title: num,
subtitleLines: names.isEmpty ? const [] : [names], subtitleLines: subtitle,
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
fallbackIcon: fallbackIcon:
isFamille ? Icons.family_restroom_outlined : Icons.face, isFamille ? Icons.family_restroom_outlined : Icons.face,
// N° = titre neutre (comme Parents / AM) ; accent = icône seule. // N° = titre neutre (comme Parents / AM) ; accent = icône seule.
avatarIconColor: accent, avatarIconColor: accent,
infoColor: Colors.black87, infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
onCardTap: onOpen, onCardTap: onOpen,
vigilanceTooltip: emptyKids
? (vigilanceTooltip ??
'Aucun enfant rattaché à ce dossier famille')
: vigilanceTooltip,
borderColor: emptyKids ? Colors.red.shade300 : null,
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Ouvrir', tooltip: 'Ouvrir',
icon: Icon(Icons.open_in_new, size: 20, color: accent), icon: Icon(Icons.open_in_new, size: 20, color: accent),
onPressed: onOpen, onPressed: onOpen,
), ),
if (onDelete != null)
suppressionIconButton(onPressed: onDelete),
], ],
); );
} }
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/pending_validation_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. /// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
class DossiersManagementWidget extends StatefulWidget { class DossiersManagementWidget extends StatefulWidget {
@@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
List<DossierListItem> _all = []; List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {}; Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0; int _pendingRefreshTick = 0;
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadAll(); _loadAll();
} }
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAll() async { Future<void> _loadAll() async {
setState(() { setState(() {
_loading = true; _loading = true;
@@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
try { try {
final parents = await UserService.getParents(); final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles(); final ams = await UserService.getAssistantesMaternelles();
Map<String, bool> sansEnfant = {};
try {
sansEnfant = await UserService.getSansEnfantByNumero();
} catch (_) {
// Flag optionnel : ne bloque pas la liste.
}
if (!mounted) return; if (!mounted) return;
final items = <DossierListItem>[ final items = <DossierListItem>[
...DossierListItem.fromParents(parents), ...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams), ...DossierListItem.fromAssistantes(ams),
]; ].map((item) {
if (!item.isFamille) return item;
final apiFlag = sansEnfant[item.numeroDossier] == true;
final localEmpty = (item.enfantsCount ?? 0) == 0;
return item.copyWith(
sansEnfant: apiFlag || localEmpty || item.sansEnfant,
);
}).toList();
items.sort((a, b) { items.sort((a, b) {
// Dossiers sans enfant en tête (#160), comme orphelins #157.
final ae = a.sansEnfant ? 0 : 1;
final be = b.sansEnfant ? 0 : 1;
if (ae != be) return ae.compareTo(be);
final byNum = a.numeroDossier.compareTo(b.numeroDossier); final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum; if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel); return a.typeLabel.compareTo(b.typeLabel);
@@ -85,6 +113,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
context: context, context: context,
builder: (context) => ValidationDossierModal( builder: (context) => ValidationDossierModal(
numeroDossier: num, numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(), onClose: () => Navigator.of(context).pop(),
onSuccess: () { onSuccess: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -94,6 +123,95 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
); );
} }
Future<void> _confirmDeleteDossier(DossierListItem item) async {
final num = item.numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
String? fallbackSummary;
try {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
final f = dossier.asFamily;
people = suppressionPeopleFromDossier(
isFamille: true,
parents: f.parents
.map((p) => (
nom: p.nom ?? '',
prenom: p.prenom ?? '',
email: p.email,
))
.toList(),
enfants: f.enfants
.map((e) => (
nom: e.lastName ?? '',
prenom: e.firstName ?? '',
))
.toList(),
);
} else {
final am = dossier.asAm.user;
people = suppressionPeopleFromDossier(
isFamille: false,
parents: const [],
enfants: const [],
amName: formatDossierPersonLabel(
nom: am.nom,
prenom: am.prenom,
email: am.email,
),
);
}
} catch (_) {
fallbackSummary = item.isFamille
? 'Tous les parents et enfants rattachés seront supprimés.'
: 'Le compte AM sera supprimé ; les enfants accueillis '
'seront conservés.';
if (item.namesLine.trim().isNotEmpty) {
for (final part in item.namesLine.split(' - ')) {
final label = part.trim();
if (label.isEmpty) continue;
people.add(SuppressionPersonLine(
label: label,
icon: item.isFamille
? Icons.supervisor_account_outlined
: Icons.face,
role: item.isFamille ? 'Parent' : 'AM',
));
}
}
}
if (!mounted) return;
final confirmed = await showDossierSuppressionConfirmDialog(
context,
numeroDossier: num,
isFamille: item.isFamille,
people: people,
fallbackSummary: fallbackSummary,
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteDossier(num);
if (!mounted) return;
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _refreshEverything();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery; final query = widget.searchQuery;
@@ -114,6 +232,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
key: ValueKey('pending-$_pendingRefreshTick'), key: ValueKey('pending-$_pendingRefreshTick'),
searchQuery: query, searchQuery: query,
compactWhenEmpty: true, compactWhenEmpty: true,
canDelete: _canDelete,
onPendingNumerosChanged: (nums) { onPendingNumerosChanged: (nums) {
if (!mounted) return; if (!mounted) return;
setState(() => _pendingNumeros = nums); setState(() => _pendingNumeros = nums);
@@ -188,7 +307,15 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
namesLine: item.namesLine, namesLine: item.namesLine,
isFamille: item.isFamille, isFamille: item.isFamille,
photoUrl: item.photoUrl, photoUrl: item.photoUrl,
sansEnfant: item.sansEnfant,
enfantsCount: item.enfantsCount,
vigilanceTooltip: item.sansEnfant
? 'Aucun enfant rattaché à ce dossier famille'
: null,
onOpen: () => _openDossier(item.numeroDossier), onOpen: () => _openDossier(item.numeroDossier),
onDelete: _canDelete
? () => _confirmDeleteDossier(item)
: null,
); );
}, },
childCount: filtered.length, childCount: filtered.length,
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/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 = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadEnfants();
}
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
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;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => ChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
onDeleted: _loadEnfants,
),
);
}
Future<({String? numero, String famille, bool isLast, String? amLabel})>
_resolveContext(
EnfantAdminModel enfant,
) async {
String? amLabel;
try {
final am = await UserService.findAmForEnfant(enfant.id);
if (am != null) {
final label = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
if (label.isNotEmpty) amLabel = label;
}
} catch (_) {}
final parentId = enfant.parentLinks
.map((l) => l.parentId.trim())
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
if (parentId.isEmpty) {
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
}
try {
final parent = await UserService.getParent(parentId);
final num = (parent.user.numeroDossier ?? '').trim();
final famille = parent.user.fullName.isNotEmpty
? parent.user.fullName
: parent.user.email;
if (num.isEmpty) {
return (
numero: null,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) {
return (
numero: num,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final n = dossier.asFamily.enfants.length;
final names = dossier.asFamily.parents
.map((p) => formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
))
.where((s) => s.isNotEmpty)
.join(' - ');
return (
numero: num,
famille: names.isNotEmpty ? names : famille,
isLast: n <= 1,
amLabel: amLabel,
);
} catch (_) {
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
}
}
Future<void> _confirmDelete(EnfantAdminModel enfant) async {
final ctx = await _resolveContext(enfant);
if (!mounted) return;
bool deleteDossier = false;
if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) {
final choice = await showDernierEnfantSuppressionDialog(
context,
enfantName: enfant.fullName,
familleLabel: ctx.famille,
numeroDossier: ctx.numero!,
amLabel: ctx.amLabel,
);
if (choice == null || !mounted) return;
deleteDossier =
choice == DernierEnfantSuppressionChoice.dossierAussi;
} else {
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'enfant',
subtitle: (ctx.numero ?? '').isEmpty
? null
: 'Dossier ${ctx.numero}',
people: [SuppressionPersonLine.enfant(enfant.fullName)],
footnotes: enfantSuppressionFootnotes(
numeroDossier: ctx.numero,
familleLabel: ctx.famille,
amLabel: ctx.amLabel,
),
);
if (!confirmed || !mounted) return;
}
try {
final result = await UserService.deleteEnfant(
enfant.id,
deleteDossier: deleteDossier,
);
if (!mounted) return;
final msg = (result['message'] ?? 'Enfant supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@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 ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return EnfantUserCard.fromEnfant(
enfant,
onCardTap: () => _openEnfant(enfant),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(enfant),
),
],
);
},
);
}
}
@@ -4,7 +4,7 @@ import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart'; import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
required String status, required String status,
@@ -29,7 +29,7 @@ List<String> enfantAdminSubtitleLines({
} }
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent. /// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
class AdminEnfantUserCard extends StatelessWidget { class EnfantUserCard extends StatelessWidget {
final String title; final String title;
final String? photoUrl; final String? photoUrl;
final List<String> subtitleLines; final List<String> subtitleLines;
@@ -40,7 +40,7 @@ class AdminEnfantUserCard extends StatelessWidget {
final Color? borderColor; final Color? borderColor;
final String? vigilanceTooltip; final String? vigilanceTooltip;
const AdminEnfantUserCard({ const EnfantUserCard({
super.key, super.key,
required this.title, required this.title,
this.photoUrl, this.photoUrl,
@@ -53,7 +53,7 @@ class AdminEnfantUserCard extends StatelessWidget {
this.vigilanceTooltip, this.vigilanceTooltip,
}); });
factory AdminEnfantUserCard.fromEnfant( factory EnfantUserCard.fromEnfant(
EnfantAdminModel enfant, { EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
@@ -67,7 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget {
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant); final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard( return EnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines( subtitleLines: enfantAdminSubtitleLines(
@@ -92,7 +92,7 @@ class AdminEnfantUserCard extends StatelessWidget {
); );
} }
factory AdminEnfantUserCard.fromSummary( factory EnfantUserCard.fromSummary(
ParentChildSummary child, { ParentChildSummary child, {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
@@ -100,7 +100,7 @@ class AdminEnfantUserCard extends StatelessWidget {
EdgeInsetsGeometry? margin, EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? contentPadding,
}) { }) {
return AdminEnfantUserCard( return EnfantUserCard(
title: child.fullName, title: child.fullName,
photoUrl: child.photoUrl, photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines( subtitleLines: enfantAdminSubtitleLines(
@@ -118,7 +118,7 @@ class AdminEnfantUserCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AdminUserCard( return UserCard(
title: title, title: title,
fallbackIcon: Icons.child_care_outlined, fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl, avatarUrl: photoUrl,
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class GestionnaireManagementWidget extends StatefulWidget { class GestionnaireManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AppUser> _gestionnaires = []; List<AppUser> _gestionnaires = [];
bool _canDelete = false;
String? _currentUserId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadGestionnaires(); _loadGestionnaires();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() {
_canDelete = canDeleteGestionnaire(user?.role);
_currentUserId = user?.id;
});
}
Future<void> _loadGestionnaires() async { Future<void> _loadGestionnaires() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -59,7 +74,7 @@ class _GestionnaireManagementWidgetState
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AdminUserFormDialog(initialUser: user); return StaffUserFormModal(initialUser: user);
}, },
); );
if (changed == true) { if (changed == true) {
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
} }
} }
Future<void> _confirmDelete(AppUser user) async {
if (_currentUserId != null && _currentUserId == user.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous ne pouvez pas supprimer votre propre compte.'),
),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer le gestionnaire',
people: [SuppressionPersonLine.gestionnaire(name)],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadGestionnaires();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -84,7 +139,9 @@ class _GestionnaireManagementWidgetState
itemCount: filteredGestionnaires.length, itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = filteredGestionnaires[index]; final user = filteredGestionnaires[index];
return AdminUserCard( final isSelf =
_currentUserId != null && _currentUserId == user.id;
return UserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined, fallbackIcon: Icons.assignment_ind_outlined,
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
_openGestionnaireEditDialog(user); _openGestionnaireEditDialog(user);
}, },
), ),
if (_canDelete && !isSelf)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
], ],
); );
}, },
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget { class ParametresPanel extends StatefulWidget {
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart';
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal]. /// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
class ParentDossierCreateModal extends StatefulWidget { class ParentDossierCreateModal extends StatefulWidget {
@@ -14,18 +14,23 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum ParentDossierWizardMode { review, create } enum ParentDossierWizardMode { review, create, edit }
/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. /// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer.
class _CreateChild { class _CreateChild {
/// Id existant en base (mode edit) — null = nouvel enfant → POST.
String? existingChildId;
String? existingPhotoUrl;
/// Statut dorigine (hors à naître) pour ne pas écraser garde/scolarise.
String? existingStatus;
final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController prenomCtrl = TextEditingController();
final TextEditingController nomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController();
@@ -34,6 +39,8 @@ class _CreateChild {
Uint8List? photoBytes; Uint8List? photoBytes;
String? photoFilename; String? photoFilename;
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
void dispose() { void dispose() {
prenomCtrl.dispose(); prenomCtrl.dispose();
nomCtrl.dispose(); nomCtrl.dispose();
@@ -41,8 +48,7 @@ class _CreateChild {
} }
} }
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) /// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
/// et [create] (création dossier famille actif par le staff, ticket #129).
class ParentDossierWizard extends StatefulWidget { class ParentDossierWizard extends StatefulWidget {
final ParentDossierWizardMode mode; final ParentDossierWizardMode mode;
final DossierFamille? dossier; final DossierFamille? dossier;
@@ -91,7 +97,25 @@ class ParentDossierWizard extends StatefulWidget {
); );
} }
factory ParentDossierWizard.edit({
Key? key,
required DossierFamille dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == ParentDossierWizardMode.create; bool get isCreate => mode == ParentDossierWizardMode.create;
bool get isEdit => mode == ParentDossierWizardMode.edit;
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
static double get shellBodyHeight => static double get shellBodyHeight =>
@@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final List<_CreateChild> _children = []; final List<_CreateChild> _children = [];
late final TextEditingController _presentationCtrl; late final TextEditingController _presentationCtrl;
/// Nb de parents déjà en base au moment de louverture (mode edit).
int _initialParentCount = 0;
/// Enfants existants retirés en edit → DELETE au save.
final List<String> _removedEnfantIds = [];
bool get _isCreate => widget.isCreate; bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierFamille get _dossier => widget.dossier!; DossierFamille get _dossier => widget.dossier!;
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
String? get _firstParentId { String? get _firstParentId {
if (_isCreate) return null; if (_isCreate) return null;
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
} }
/// Co-parent déjà présent au chargement (edit) — pas un ajout via POST.
bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2;
static String _v(String? s) => static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
@@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
_presentationCtrl = TextEditingController(); _presentationCtrl = TextEditingController();
if (_isCreate) { if (_isCreate) {
_children.add(_CreateChild()); _children.add(_CreateChild());
} else if (_isEdit) {
_prefillFromDossier();
} }
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
} }
void _prefillFromDossier() {
final parents = _dossier.parents;
_initialParentCount = parents.length;
if (parents.isNotEmpty) {
final p1 = parents.first;
_p1NomCtrl.text = (p1.nom ?? '').trim();
_p1PrenomCtrl.text = (p1.prenom ?? '').trim();
_p1TelCtrl.text = (p1.telephone ?? '').trim();
_p1EmailCtrl.text = (p1.email).trim();
_p1AdresseCtrl.text = (p1.adresse ?? '').trim();
_p1CpCtrl.text = (p1.codePostal ?? '').trim();
_p1VilleCtrl.text = (p1.ville ?? '').trim();
if (parents.length >= 2) {
_hasCoParent = true;
final p2 = parents[1];
_p2NomCtrl.text = (p2.nom ?? '').trim();
_p2PrenomCtrl.text = (p2.prenom ?? '').trim();
_p2TelCtrl.text = (p2.telephone ?? '').trim();
_p2EmailCtrl.text = (p2.email).trim();
_p2AdresseCtrl.text = (p2.adresse ?? '').trim();
_p2CpCtrl.text = (p2.codePostal ?? '').trim();
_p2VilleCtrl.text = (p2.ville ?? '').trim();
} else {
_hasCoParent = false;
}
}
_prefillChildrenFromDossier();
}
void _prefillChildrenFromDossier() {
for (final c in _children) {
c.dispose();
}
_children.clear();
_removedEnfantIds.clear();
final enfants = _dossier.enfants;
if (enfants.isEmpty) {
_children.add(_CreateChild());
return;
}
for (final e in enfants) {
final status = (e.status ?? '').trim().toLowerCase();
final id = e.id.trim();
final child = _CreateChild()
..existingChildId = id.isEmpty ? null : id
..existingPhotoUrl = e.photoUrl
..existingStatus = status.isEmpty ? null : status
..isUnborn = status == 'a_naitre';
child.prenomCtrl.text = (e.firstName ?? '').trim();
child.nomCtrl.text = (e.lastName ?? '').trim();
final g = (e.gender ?? '').trim();
final gUp = g.toUpperCase();
if (gUp == 'H' || gUp == 'F') {
child.genre = gUp;
} else if (g == 'Autre' || child.isUnborn) {
child.genre = child.isUnborn ? 'Autre' : g;
}
final dateSrc = child.isUnborn ? e.dueDate : e.birthDate;
child.dateCtrl.text = formatIsoDateFrInput(dateSrc);
_children.add(child);
}
}
@override @override
void dispose() { void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades); _enfantsScrollController.removeListener(_syncEnfantsScrollFades);
@@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildStep0() { Widget _buildStep0() {
if (_isCreate) { if (_isEditable) {
return IdentityBlock.editable( return IdentityBlock.editable(
title: 'Parent principal', title: 'Parent principal',
nomController: _p1NomCtrl, nomController: _p1NomCtrl,
@@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildStep1() { Widget _buildStep1() {
if (_isCreate) { if (_isEditable) {
return _buildCoParentStepCreate(); return _buildCoParentStepEditable(
allowToggle: !_hadExistingCoParent,
);
} }
return _buildParent2Step(); return _buildParent2Step();
} }
Widget _buildCoParentStepCreate() { Widget _buildCoParentStepEditable({required bool allowToggle}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -330,6 +437,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
color: Colors.black87, color: Colors.black87,
), ),
), ),
if (allowToggle) ...[
const Spacer(), const Spacer(),
const Text( const Text(
'Ajouter un co-parent', 'Ajouter un co-parent',
@@ -344,6 +452,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
), ),
), ),
], ],
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (!_hasCoParent) if (!_hasCoParent)
@@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildEnfantsStep() { Widget _buildEnfantsStep() {
if (_isCreate) { if (_isCreate || _isEdit) {
return _buildEnfantsStepCreate(); return _buildEnfantsStepCreate();
} }
final enfants = _dossier.enfants; final enfants = _dossier.enfants;
@@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
if (_children.length <= 1) return; if (_children.length <= 1) return;
setState(() { setState(() {
final removed = _children.removeAt(index); final removed = _children.removeAt(index);
final existingId = (removed.existingChildId ?? '').trim();
if (existingId.isNotEmpty) {
_removedEnfantIds.add(existingId);
}
removed.dispose(); removed.dispose();
}); });
} }
@@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), // Réserve langle haut-droit pour la croix de suppression.
padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8),
child: ValidationLabeledField( child: ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: ValidationEditableField( field: ValidationEditableField(
@@ -746,7 +860,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
child: SizedBox( child: SizedBox(
width: pw, width: pw,
height: ph, height: ph,
child: AdminAmPhotoFrame( child: AmPhotoFrame(
photoUrl: child.photoBytes == null
? child.existingPhotoUrl
: null,
imageBytes: child.photoBytes, imageBytes: child.photoBytes,
onTap: () => _pickChildPhoto(index), onTap: () => _pickChildPhoto(index),
onClear: child.photoBytes != null onClear: child.photoBytes != null
@@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
), ),
if (canRemove) if (canRemove)
Positioned( Positioned(
top: 4, top: 6,
right: 4, right: 6,
child: Material( child: Material(
color: Colors.transparent, color: Colors.white,
child: IconButton( elevation: 1,
tooltip: 'Retirer cet enfant', shape: const CircleBorder(),
visualDensity: VisualDensity.compact, clipBehavior: Clip.antiAlias,
padding: EdgeInsets.zero, child: InkWell(
constraints: customBorder: const CircleBorder(),
const BoxConstraints(minWidth: 32, minHeight: 32), onTap: () => _removeChild(index),
icon: Icon(Icons.close, child: Tooltip(
size: 18, color: Colors.grey.shade700), message: 'Retirer cet enfant',
onPressed: () => _removeChild(index), child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.close,
size: 14,
color: Colors.grey.shade800,
),
),
),
), ),
), ),
), ),
@@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
String? _validateCurrentStep() { String? _validateCurrentStep() {
if (_isEdit) {
switch (_step) {
case 0:
return _validateP1();
case 1:
return _validateP2();
case 2:
return _validateEnfants();
default:
return null;
}
}
if (!_isCreate) return null; if (!_isCreate) return null;
switch (_step) { switch (_step) {
case 0: case 0:
@@ -1328,7 +1466,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': c.genre, 'genre': c.genre,
'consent_photo': true, 'consent_photo': true,
'grossesse_multiple': false,
}; };
if (prenom.length >= 2) { if (prenom.length >= 2) {
@@ -1440,6 +1577,298 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
} }
Map<String, dynamic> _parentFicheBody({
required TextEditingController nom,
required TextEditingController prenom,
required TextEditingController email,
required TextEditingController tel,
required TextEditingController adresse,
required TextEditingController cp,
required TextEditingController ville,
}) {
return {
'nom': formatPersonNameCase(nom.text),
'prenom': formatPersonNameCase(prenom.text),
'email': normalizeEmailText(email.text),
'telephone': normalizePhone(tel.text),
'adresse': adresse.text.trim(),
'ville': formatPersonNameCase(ville.text),
'code_postal': cp.text.trim(),
};
}
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateP1();
if (err0 != null) {
_showError(err0);
return;
}
final err1 = _validateP2();
if (err1 != null) {
_showError(err1);
return;
}
final err2 = _validateEnfants();
if (err2 != null) {
_showError(err2);
return;
}
final pivotId = (_firstParentId ?? '').trim();
if (pivotId.isEmpty) {
_showError('Identifiant du parent principal manquant.');
return;
}
final addingCoParent = _hasCoParent && !_hadExistingCoParent;
final ok = await showValidationValiderConfirmDialog(
context,
body: addingCoParent
? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.'
: 'Enregistrer les modifications du dossier famille ?',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.updateParentFiche(
parentUserId: pivotId,
body: _parentFicheBody(
nom: _p1NomCtrl,
prenom: _p1PrenomCtrl,
email: _p1EmailCtrl,
tel: _p1TelCtrl,
adresse: _p1AdresseCtrl,
cp: _p1CpCtrl,
ville: _p1VilleCtrl,
),
);
if (_hadExistingCoParent) {
final coId = _dossier.parents[1].id.trim();
if (coId.isEmpty) {
throw Exception('Identifiant du co-parent manquant.');
}
await UserService.updateParentFiche(
parentUserId: coId,
body: _parentFicheBody(
nom: _p2NomCtrl,
prenom: _p2PrenomCtrl,
email: _p2EmailCtrl,
tel: _p2TelCtrl,
adresse: _p2AdresseCtrl,
cp: _p2CpCtrl,
ville: _p2VilleCtrl,
),
);
} else if (addingCoParent) {
if (_sameAddress) _copyP1AddressToP2();
final body = <String, dynamic>{
'email': normalizeEmailText(_p2EmailCtrl.text),
'prenom': formatPersonNameCase(_p2PrenomCtrl.text),
'nom': formatPersonNameCase(_p2NomCtrl.text),
'telephone': normalizePhone(_p2TelCtrl.text),
'meme_adresse': _sameAddress,
};
if (!_sameAddress) {
body['adresse'] = _p2AdresseCtrl.text.trim();
body['code_postal'] = _p2CpCtrl.text.trim();
body['ville'] = formatPersonNameCase(_p2VilleCtrl.text);
}
await UserService.addCoParent(pivotId, body: body);
}
await _saveEnfantsEdit(pivotId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
addingCoParent
? 'Dossier enregistré. Co-parent ajouté.'
: 'Dossier enregistré.',
),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Map<String, dynamic> _enfantStaffBody(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
final dateIso = parseFrDateToIso(c.dateCtrl.text);
final existingStatus = (c.existingStatus ?? '').trim().toLowerCase();
final status = c.isUnborn
? 'a_naitre'
: (existingStatus.isNotEmpty && existingStatus != 'a_naitre'
? existingStatus
: 'sans_garde');
final gender = c.genre ?? 'H';
final map = <String, dynamic>{
'status': status,
'gender': gender,
'consent_photo': true,
};
if (prenom.length >= 2) map['first_name'] = prenom;
if (nom.length >= 2) map['last_name'] = nom;
if (c.isUnborn) {
if (dateIso != null) map['due_date'] = dateIso;
} else if (dateIso != null) {
map['birth_date'] = dateIso;
}
return map;
}
/// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET.
String _enfantIdentityKey({
required String prenom,
required String nom,
required String? dateIso,
required bool isUnborn,
}) {
final pn = formatPersonNameCase(prenom).toLowerCase().trim();
final nm = formatPersonNameCase(nom).toLowerCase().trim();
final d = _normalizeDateKey(dateIso);
final kind = isUnborn ? 'due' : 'birth';
return '$pn|$nm|$kind|$d';
}
String _normalizeDateKey(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return '';
final fromFr = parseFrDateToIso(s);
if (fromFr != null) return fromFr;
try {
final dt = DateTime.parse(s);
final y = dt.year.toString().padLeft(4, '0');
final m = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
return '$y-$m-$d';
} catch (_) {
return s;
}
}
String _enfantKeyFromCreate(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
return _enfantIdentityKey(
prenom: prenom,
nom: nom,
dateIso: parseFrDateToIso(c.dateCtrl.text),
isUnborn: c.isUnborn,
);
}
String _enfantKeyFromDossier(EnfantDossier e) {
final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final nomRaw = (e.lastName ?? '').trim();
final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim());
return _enfantIdentityKey(
prenom: e.firstName ?? '',
nom: nom,
dateIso: isUnborn ? e.dueDate : e.birthDate,
isUnborn: isUnborn,
);
}
bool _isBlankChildDraft(_CreateChild c) {
if (c.hasExistingId) return false;
final prenom = c.prenomCtrl.text.trim();
final nom = c.nomCtrl.text.trim();
final date = c.dateCtrl.text.trim();
return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null;
}
/// Résout lid enfant : tracker edit, sinon match sur le GET dossier.
String? _resolveExistingChildId(
_CreateChild c,
Map<String, String> dossierIdByIdentity,
Set<String> alreadyUsedIds,
) {
final tracked = (c.existingChildId ?? '').trim();
if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) {
return tracked;
}
final key = _enfantKeyFromCreate(c);
final matched = (dossierIdByIdentity[key] ?? '').trim();
if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) {
return matched;
}
return null;
}
Future<void> _saveEnfantsEdit(String pivotUserId) async {
// Index id connus du GET (ne jamais POST pour ceux-là).
final dossierIdByIdentity = <String, String>{};
for (final e in _dossier.enfants) {
final id = e.id.trim();
if (id.isEmpty) continue;
dossierIdByIdentity[_enfantKeyFromDossier(e)] = id;
}
for (final id in _removedEnfantIds) {
await UserService.deleteEnfant(id);
}
final usedIds = <String>{..._removedEnfantIds};
for (final c in _children) {
if (_isBlankChildDraft(c)) continue;
final existingId = _resolveExistingChildId(
c,
dossierIdByIdentity,
usedIds,
);
final body = _enfantStaffBody(c);
if (existingId != null && existingId.isNotEmpty) {
// Ré-attache lid au modèle (si match identité a récupéré un id perdu).
c.existingChildId = existingId;
final bytes = c.photoBytes;
await UserService.updateEnfant(
enfantId: existingId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
usedIds.add(existingId);
continue;
}
// Uniquement les vrais nouveaux enfants (pas did dossier).
final bytes = c.photoBytes;
final created = await UserService.createEnfant(
parentUserId: pivotUserId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
final newId = created.id.trim();
if (newId.isNotEmpty) {
c.existingChildId = newId;
usedIds.add(newId);
}
}
}
Widget _buildNavigation() { Widget _buildNavigation() {
if (_step == 3) { if (_step == 3) {
return Row( return Row(
@@ -1463,6 +1892,12 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
onPressed: _submitting ? null : _createAndValidate, onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
), ),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente && _firstParentId != null) ...[ ] else if (_isEnAttente && _firstParentId != null) ...[
OutlinedButton( OutlinedButton(
onPressed: _submitting ? null : _refuser, onPressed: _submitting ? null : _refuser,
@@ -4,30 +4,30 @@ import 'package:p_tits_pas/models/parent_child_summary.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/user.dart'; import 'package:p_tits_pas/models/user.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_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. /// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
class AdminParentEditModal extends StatefulWidget { class ParentEditModal extends StatefulWidget {
final ParentModel parent; final ParentModel parent;
final VoidCallback? onSaved; final VoidCallback? onSaved;
const AdminParentEditModal({ const ParentEditModal({
super.key, super.key,
required this.parent, required this.parent,
this.onSaved, this.onSaved,
}); });
@override @override
State<AdminParentEditModal> createState() => _AdminParentEditModalState(); State<ParentEditModal> createState() => _ParentEditModalState();
} }
class _AdminParentEditModalState extends State<AdminParentEditModal> { class _ParentEditModalState extends State<ParentEditModal> {
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl; late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl; late final TextEditingController _emailCtrl;
@@ -129,7 +129,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminParentEditModal( builder: (ctx) => ParentEditModal(
parent: parent, parent: parent,
onSaved: () async { onSaved: () async {
try { try {
@@ -244,7 +244,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminChildDetailModal( builder: (ctx) => ChildDetailModal(
enfant: enfant, enfant: enfant,
onSaved: _reloadChildren, onSaved: _reloadChildren,
), ),
@@ -322,7 +322,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (!mounted) return; if (!mounted) return;
final selected = await AdminSelectEnfantModal.show( final selected = await SelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant', title: 'Rattacher un enfant',
@@ -350,7 +350,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
} }
Widget _childrenPanel() { Widget _childrenPanel() {
return AdminChildrenAffiliationPanel( return ChildrenAffiliationPanel(
children: _children, children: _children,
scrollController: _childrenScrollCtrl, scrollController: _childrenScrollCtrl,
onOpen: _openChild, onOpen: _openChild,
@@ -437,7 +437,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 160, width: 160,
child: AdminStatusCapsule( child: StatusCapsule(
statut: _statut, statut: _statut,
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_statut = v; _statut = v;
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class ParentManagementWidget extends StatefulWidget { class ParentManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<ParentModel> _parents = []; List<ParentModel> _parents = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadParents(); _loadParents();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadParents() async { Future<void> _loadParents() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
} }
} }
bool _isLastParent(ParentModel parent) {
final co = parent.coParent?.id.trim();
if (co != null && co.isNotEmpty) return false;
final num = (parent.user.numeroDossier ?? '').trim();
if (num.isEmpty) return true;
return !_parents.any((other) {
if (other.user.id == parent.user.id) return false;
return (other.user.numeroDossier ?? '').trim() == num;
});
}
Future<void> _confirmDelete(ParentModel parent) async {
final num = (parent.user.numeroDossier ?? '').trim();
final name = formatDossierPersonLabel(
nom: parent.user.nom,
prenom: parent.user.prenom,
email: parent.user.email,
);
final last = _isLastParent(parent);
final enfants = ParentModel.foyerChildrenCount(parent, _parents);
final footnotes = last
? <String>[
if (num.isNotEmpty) 'Dernier parent du dossier $num.',
if (num.isEmpty) 'Dernier parent du dossier.',
'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.',
]
: <String>[
if (num.isNotEmpty)
'Ce parent sera retiré du dossier $num.',
'Les enfants restent avec le co-parent.',
];
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer le parent',
subtitle: num.isEmpty ? null : 'Dossier $num',
people: [SuppressionPersonLine.parent(name)],
footnotes: footnotes,
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(parent.user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'Parent supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadParents();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -73,7 +146,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
itemCount: filteredParents.length, itemCount: filteredParents.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final parent = filteredParents[index]; final parent = filteredParents[index];
return AdminUserCard( return UserCard(
title: parent.user.fullName, title: parent.user.fullName,
fallbackIcon: Icons.supervisor_account_outlined, fallbackIcon: Icons.supervisor_account_outlined,
avatarUrl: parent.user.photoUrl, avatarUrl: parent.user.photoUrl,
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
_openParentDetails(parent); _openParentDetails(parent);
}, },
), ),
if (_canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
], ],
); );
}, },
@@ -114,7 +189,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
void _openParentDetails(ParentModel parent) { void _openParentDetails(ParentModel parent) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AdminParentEditModal( builder: (context) => ParentEditModal(
parent: parent, parent: parent,
onSaved: _loadParents, onSaved: _loadParents,
), ),
@@ -3,8 +3,9 @@ import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/pending_family.dart'; import 'package:p_tits_pas/models/pending_family.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/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
class PendingValidationWidget extends StatefulWidget { class PendingValidationWidget extends StatefulWidget {
@@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget {
final bool compactWhenEmpty; final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? onPendingNumerosChanged; final ValueChanged<Set<String>>? onPendingNumerosChanged;
/// Afficher la poubelle (#160) — mêmes règles que dossiers validés.
final bool canDelete;
const PendingValidationWidget({ const PendingValidationWidget({
super.key, super.key,
@@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget {
this.searchQuery = '', this.searchQuery = '',
this.compactWhenEmpty = false, this.compactWhenEmpty = false,
this.onPendingNumerosChanged, this.onPendingNumerosChanged,
this.canDelete = false,
}); });
@override @override
@@ -141,6 +145,97 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
); );
} }
Future<void> _confirmDeletePending({
required String numeroDossier,
required String namesLine,
required bool isFamille,
}) async {
final num = numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
String? fallbackSummary;
try {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
final f = dossier.asFamily;
people = suppressionPeopleFromDossier(
isFamille: true,
parents: f.parents
.map((p) => (
nom: p.nom ?? '',
prenom: p.prenom ?? '',
email: p.email,
))
.toList(),
enfants: f.enfants
.map((e) => (
nom: e.lastName ?? '',
prenom: e.firstName ?? '',
))
.toList(),
);
} else {
final am = dossier.asAm.user;
people = suppressionPeopleFromDossier(
isFamille: false,
parents: const [],
enfants: const [],
amName: formatDossierPersonLabel(
nom: am.nom,
prenom: am.prenom,
email: am.email,
),
);
}
} catch (_) {
fallbackSummary = isFamille
? 'Tous les parents et enfants rattachés seront supprimés.'
: 'Le compte AM sera supprimé ; les enfants accueillis '
'seront conservés.';
for (final part in namesLine.split(' - ')) {
final label = part.trim();
if (label.isEmpty) continue;
people.add(SuppressionPersonLine(
label: label,
icon: isFamille
? Icons.supervisor_account_outlined
: Icons.face,
role: isFamille ? 'Parent' : 'AM',
));
}
}
if (!mounted) return;
final confirmed = await showDossierSuppressionConfirmDialog(
context,
numeroDossier: num,
isFamille: isFamille,
people: people,
fallbackSummary: fallbackSummary,
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteDossier(num);
if (!mounted) return;
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _load();
widget.onRefresh?.call();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
bool _matchesQuery(String haystack) { bool _matchesQuery(String haystack) {
final q = widget.searchQuery.trim().toLowerCase(); final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true; if (q.isEmpty) return true;
@@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
} }
Widget _buildAMCard(AppUser user) { Widget _buildAMCard(AppUser user) {
final names = _amNamesLine(user);
final num = user.numeroDossier ?? '';
return DossierListCard( return DossierListCard(
numeroDossier: user.numeroDossier ?? '', numeroDossier: num,
namesLine: _amNamesLine(user), namesLine: names,
isFamille: false, isFamille: false,
photoUrl: user.photoUrl, photoUrl: user.photoUrl,
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: false,
)
: null,
); );
} }
@@ -306,6 +410,13 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
namesLine: names, namesLine: names,
isFamille: true, isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: true,
)
: null,
); );
} }
} }
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
class RelaisManagementPanel extends StatefulWidget { class RelaisManagementPanel extends StatefulWidget {
const RelaisManagementPanel({super.key}); const RelaisManagementPanel({super.key});
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
try { try {
if (result.action == _RelaisDialogAction.delete) { if (result.action == _RelaisDialogAction.delete) {
if (relais == null) return; if (relais == null) return;
final confirmed = await showDialog<bool>( final confirmed = await showSuppressionConfirmDialog(
context: context, context,
builder: (ctx) => AlertDialog( title: 'Supprimer le relais',
title: const Text('Supprimer le relais'), people: [SuppressionPersonLine.relais(relais.nom)],
content: Text('Confirmer la suppression de "${relais.nom}" ?'), footnotes: const [
actions: [ 'Cette action est irréversible.',
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
),
child: const Text('Supprimer'),
),
], ],
),
); );
if (confirmed != true) return; if (!confirmed) return;
await RelaisService.deleteRelais(relais.id); await RelaisService.deleteRelais(relais.id);
} else if (relais == null) { } else if (relais == null) {
await RelaisService.createRelais(result.payload!); await RelaisService.createRelais(result.payload!);
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) { List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[]; final lines = <String>[];
@@ -28,22 +28,22 @@ List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
} }
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. /// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146). /// S'appuie sur [SelectListModal] (shell partagé avec #146).
class AdminSelectAmModal { class SelectAmModal {
AdminSelectAmModal._(); SelectAmModal._();
static Future<AssistanteMaternelleModel?> show( static Future<AssistanteMaternelleModel?> show(
BuildContext context, { BuildContext context, {
Set<String> excludeIds = const {}, Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle', String title = 'Choisir une assistante maternelle',
}) { }) {
return AdminSelectListModal.show<AssistanteMaternelleModel>( return SelectListModal.show<AssistanteMaternelleModel>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par nom, prénom ou zone…', searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible', emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche', noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>( toggleFilter: const SelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre', label: 'Libre',
initialValue: true, initialValue: true,
whenEnabled: amHasFreePlace, whenEnabled: amHasFreePlace,
@@ -76,7 +76,7 @@ class AdminSelectAmModal {
_resolveAmSelection(ctx, am, reload), _resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) { itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am); final full = !amHasFreePlace(am);
return AdminUserCard( return UserCard(
title: am.user.fullName, title: am.user.fullName,
avatarUrl: am.user.photoUrl, avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face, fallbackIcon: Icons.face,
@@ -150,7 +150,7 @@ class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AdminAmEditModal( builder: (ctx) => AmEditModal(
assistante: _am, assistante: _am,
onSaved: () async { onSaved: () async {
try { try {
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.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/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. /// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147). /// S'appuie sur [SelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal { class SelectEnfantModal {
AdminSelectEnfantModal._(); SelectEnfantModal._();
static Future<EnfantAdminModel?> show( static Future<EnfantAdminModel?> show(
BuildContext context, { BuildContext context, {
@@ -17,7 +17,7 @@ class AdminSelectEnfantModal {
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false, bool showSansGardeFilter = false,
}) { }) {
return AdminSelectListModal.show<EnfantAdminModel>( return SelectListModal.show<EnfantAdminModel>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par nom ou prénom…', searchHint: 'Rechercher par nom ou prénom…',
@@ -26,7 +26,7 @@ class AdminSelectEnfantModal {
? 'Aucun enfant sans garde pour cette recherche' ? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche', : 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>( ? SelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde', label: 'Sans garde',
initialValue: true, initialValue: true,
whenEnabled: (e) => whenEnabled: (e) =>
@@ -50,7 +50,7 @@ class AdminSelectEnfantModal {
return name.contains(q) || fn.contains(q) || ln.contains(q); return name.contains(q) || fn.contains(q) || ln.contains(q);
}, },
itemBuilder: (context, e, onSelect) { itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant( return EnfantUserCard.fromEnfant(
e, e,
onCardTap: onSelect, onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157). /// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer { class FamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`). /// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId; final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157). /// Co-parent éventuel (rattachement foyer #157).
@@ -14,7 +14,7 @@ class AdminFamilleFoyer {
final String displayTitle; final String displayTitle;
final List<String> parentNames; final List<String> parentNames;
const AdminFamilleFoyer({ const FamilleFoyer({
required this.pivotParentUserId, required this.pivotParentUserId,
required this.displayTitle, required this.displayTitle,
required this.parentNames, required this.parentNames,
@@ -42,10 +42,10 @@ class AdminFamilleFoyer {
} }
/// Construit la liste des foyers uniques à partir de `GET /parents`. /// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) { List<FamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{}; final seenDossiers = <String>{};
final seenUserIds = <String>{}; final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[]; final foyers = <FamilleFoyer>[];
for (final p in parents) { for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim(); final dossier = (p.user.numeroDossier ?? '').trim();
@@ -70,7 +70,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
: (names.isNotEmpty ? names.first : 'Famille'); : (names.isNotEmpty ? names.first : 'Famille');
foyers.add( foyers.add(
AdminFamilleFoyer( FamilleFoyer(
pivotParentUserId: p.user.id, pivotParentUserId: p.user.id,
coParentUserId: co?.id, coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null, numeroDossier: dossier.isNotEmpty ? dossier : null,
@@ -87,14 +87,14 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
} }
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132. /// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal { class SelectFamilleModal {
AdminSelectFamilleModal._(); SelectFamilleModal._();
static Future<AdminFamilleFoyer?> show( static Future<FamilleFoyer?> show(
BuildContext context, { BuildContext context, {
String title = 'Choisir une famille', String title = 'Choisir une famille',
}) { }) {
return AdminSelectListModal.show<AdminFamilleFoyer>( return SelectListModal.show<FamilleFoyer>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par dossier, nom…', searchHint: 'Rechercher par dossier, nom…',
@@ -111,7 +111,7 @@ class AdminSelectFamilleModal {
return dossier.contains(q) || title.contains(q) || names.contains(q); return dossier.contains(q) || title.contains(q) || names.contains(q);
}, },
itemBuilder: (context, f, onSelect) { itemBuilder: (context, f, onSelect) {
return AdminUserCard( return UserCard(
title: f.displayTitle, title: f.displayTitle,
fallbackIcon: Icons.family_restroom, fallbackIcon: Icons.family_restroom,
subtitleLines: [ subtitleLines: [
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche. /// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> { class SelectToggleFilter<T> {
final String label; final String label;
final bool initialValue; final bool initialValue;
@@ -10,7 +10,7 @@ class AdminSelectToggleFilter<T> {
/// [whenEnabled] renvoie `true`. /// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled; final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({ const SelectToggleFilter({
required this.label, required this.label,
required this.whenEnabled, required this.whenEnabled,
this.initialValue = true, this.initialValue = true,
@@ -19,7 +19,7 @@ class AdminSelectToggleFilter<T> {
/// Shell générique « rechercher + liste + sélection » pour les modales admin. /// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). /// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget { class SelectListModal<T> extends StatefulWidget {
final String title; final String title;
final String searchHint; final String searchHint;
final Future<List<T>> Function() loadItems; final Future<List<T>> Function() loadItems;
@@ -40,7 +40,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
final int minVisibleCards; final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter; final SelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection. /// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
@@ -50,7 +50,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
Future<void> Function() reload, Future<void> Function() reload,
)? resolveSelect; )? resolveSelect;
const AdminSelectListModal({ const SelectListModal({
super.key, super.key,
required this.title, required this.title,
required this.loadItems, required this.loadItems,
@@ -82,7 +82,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
double modalWidth = 930, double modalWidth = 930,
double cardExtent = 52, double cardExtent = 52,
int minVisibleCards = 8, int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter, SelectToggleFilter<T>? toggleFilter,
Future<T?> Function( Future<T?> Function(
BuildContext context, BuildContext context,
T item, T item,
@@ -91,7 +91,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
}) { }) {
return showDialog<T>( return showDialog<T>(
context: context, context: context,
builder: (ctx) => AdminSelectListModal<T>( builder: (ctx) => SelectListModal<T>(
title: title, title: title,
loadItems: loadItems, loadItems: loadItems,
matchesQuery: matchesQuery, matchesQuery: matchesQuery,
@@ -109,11 +109,11 @@ class AdminSelectListModal<T> extends StatefulWidget {
} }
@override @override
State<AdminSelectListModal<T>> createState() => State<SelectListModal<T>> createState() =>
_AdminSelectListModalState<T>(); _SelectListModalState<T>();
} }
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> { class _SelectListModalState<T> extends State<SelectListModal<T>> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
List<T> _all = []; List<T> _all = [];
bool _loading = true; bool _loading = true;
@@ -0,0 +1,794 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Modale création / édition / consultation staff (gestionnaire / admin) — #164.
class StaffUserFormModal extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const StaffUserFormModal({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<StaffUserFormModal> createState() => _StaffUserFormModalState();
}
class _StaffUserFormModalState extends State<StaffUserFormModal> {
static const double _modalWidth = 930;
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
bool _isLoadingRelais = true;
bool _dirty = false;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
String? _currentUserRole;
String _baselineNom = '';
String _baselinePrenom = '';
String _baselineEmail = '';
String _baselinePhone = '';
String? _baselineRelaisId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
bool get _fieldsEnabled => !widget.readOnly && !_isSubmitting;
String get _targetRoleKey {
if (widget.initialUser != null) {
return (widget.initialUser!.role).toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
default:
return Icons.person_outline;
}
}
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
_captureBaseline();
}
for (final c in [
_nomController,
_prenomController,
_emailController,
_passwordController,
_telephoneController,
]) {
c.addListener(_onFieldChanged);
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
_loadCurrentUserId();
}
void _captureBaseline() {
_baselineNom = formatPersonNameCase(_nomController.text);
_baselinePrenom = formatPersonNameCase(_prenomController.text);
_baselineEmail = normalizeEmailText(_emailController.text);
_baselinePhone = normalizePhone(_telephoneController.text);
_baselineRelaisId = _selectedRelaisId;
}
void _onFieldChanged() {
if (widget.readOnly || !_isEditMode) return;
final dirty = _computeDirty();
if (dirty != _dirty) setState(() => _dirty = dirty);
}
bool _computeDirty() {
if (!_isEditMode) return true;
final nom = formatPersonNameCase(_nomController.text);
final prenom = formatPersonNameCase(_prenomController.text);
final email = normalizeEmailText(_emailController.text);
final phone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
return nom != _baselineNom ||
prenom != _baselinePrenom ||
email != _baselineEmail ||
phone != _baselinePhone ||
passwordProvided ||
_selectedRelaisId != _baselineRelaisId;
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
_currentUserRole = cached.role;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
});
}
@override
void dispose() {
for (final c in [
_nomController,
_prenomController,
_emailController,
_passwordController,
_telephoneController,
]) {
c.removeListener(_onFieldChanged);
c.dispose();
}
_passwordToggleFocusNode.dispose();
super.dispose();
}
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
filtered.addAll(_fallbackRelaisFromUser());
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (_isEditMode && !_dirty) return;
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
final normalizedNom = formatPersonNameCase(_nomController.text);
final normalizedPrenom = formatPersonNameCase(_prenomController.text);
final normalizedPhone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = formatPersonNameCase(widget.initialUser!.nom ?? '');
final lockedPrenom =
formatPersonNameCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: normalizeEmailText(_emailController.text),
telephone: normalizedPhone.isEmpty
? normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = formatPersonNameCase(currentUser.nom ?? '');
final initialPrenom = formatPersonNameCase(currentUser.prenom ?? '');
final initialEmail = normalizeEmailText(currentUser.email);
final initialPhone = normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged = normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
normalizeEmailText(_emailController.text) == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: normalizeEmailText(_emailController.text),
telephone:
normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: normalizeEmailText(_emailController.text),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: normalizeEmailText(_emailController.text),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: widget.adminMode
? 'Supprimer l\'administrateur'
: 'Supprimer le gestionnaire',
people: [
widget.adminMode
? SuppressionPersonLine.administrateur(name)
: SuppressionPersonLine.gestionnaire(name),
],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed) return;
setState(() => _isSubmitting = true);
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.adminMode
? 'Administrateur supprimé.'
: 'Gestionnaire supprimé.',
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() => _isSubmitting = false);
}
}
String _headerTitle() {
if (!_isEditMode) {
return widget.adminMode
? 'Créer un administrateur'
: 'Créer un gestionnaire';
}
final prenom = (_prenomController.text.trim().isNotEmpty
? _prenomController.text
: (widget.initialUser?.prenom ?? ''))
.trim();
final nom = (_nomController.text.trim().isNotEmpty
? _nomController.text
: (widget.initialUser?.nom ?? ''))
.trim();
final full = '$prenom $nom'.trim();
if (full.isNotEmpty) return full;
return widget.initialUser?.email ?? _targetRoleLabel;
}
Widget _buildFooter() {
if (widget.readOnly) {
return Row(
children: [
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
],
);
}
if (!_isEditMode) {
return Row(
children: [
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
const Spacer(),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Créer'),
),
],
);
}
return Row(
children: [
if (_canDeleteTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
),
child: const Text('Supprimer'),
),
if (_canDeleteTarget) const SizedBox(width: 8),
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
const Spacer(),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
Widget _namedField({
required String label,
required TextEditingController controller,
required String requiredLabel,
bool enabled = true,
}) {
return ValidationLabeledField(
label: label,
field: SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: TextFormField(
controller: controller,
enabled: enabled,
textCapitalization: TextCapitalization.words,
textAlignVertical: TextAlignVertical.center,
inputFormatters: const [PersonNameInputFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(),
validator: (!enabled || widget.readOnly)
? null
: (v) => _required(v, requiredLabel),
),
),
);
}
Widget _passwordField() {
return ValidationLabeledField(
label: _isEditMode ? 'Nouveau mot de passe' : 'Mot de passe',
field: SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: TextFormField(
controller: _passwordController,
enabled: _fieldsEnabled,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input().copyWith(
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: !_fieldsEnabled
? null
: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
),
),
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: widget.readOnly ? null : _validatePassword,
),
),
);
}
Widget _relaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return ValidationLabeledField(
label: 'Relais principal',
field: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: ValidationFieldDecoration.input(),
style: ValidationFormMetrics.fieldTextStyle,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || !_fieldsEnabled)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
if (_isEditMode) {
_dirty = _computeDirty();
}
});
},
),
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
),
);
}
@override
Widget build(BuildContext context) {
final nameEnabled =
_fieldsEnabled && !_isLockedAdminIdentity;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, right: 10),
child: Icon(
_targetRoleIcon,
size: 22,
color: ValidationModalTheme.primaryActionBackground,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_isEditMode) ...[
const SizedBox(height: 4),
Text(
_targetRoleLabel,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
tooltip: 'Fermer',
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _namedField(
label: 'Prénom',
controller: _prenomController,
requiredLabel: 'Prénom',
enabled: nameEnabled,
),
),
const SizedBox(width: 12),
Expanded(
child: _namedField(
label: 'Nom',
controller: _nomController,
requiredLabel: 'Nom',
enabled: nameEnabled,
),
),
],
),
SizedBox(height: ValidationFormMetrics.rowGapBelow),
ValidationLabeledField(
label: 'Email',
field: IgnorePointer(
ignoring: !_fieldsEnabled,
child: ValidationEmailField(
controller: _emailController,
hintText: 'ex. nom@domaine.fr',
),
),
),
SizedBox(height: ValidationFormMetrics.rowGapBelow),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _passwordField()),
const SizedBox(width: 12),
Expanded(
child: ValidationLabeledField(
label: 'Téléphone',
field: IgnorePointer(
ignoring: !_fieldsEnabled,
child: ValidationPhoneField(
controller: _telephoneController,
hintText: '06 12 34 56 78',
allowEmpty: _isEditMode,
),
),
),
),
],
),
if (widget.withRelais) ...[
SizedBox(height: ValidationFormMetrics.rowGapBelow),
_relaisField(),
],
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 16, 14),
child: _buildFooter(),
),
],
),
),
),
);
}
}
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). /// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget { class StatusCapsule extends StatelessWidget {
final String statut; final String statut;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({ const StatusCapsule({
super.key, super.key,
required this.statut, required this.statut,
this.onChanged, this.onChanged,
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class AdminUserCard extends StatefulWidget { class UserCard extends StatefulWidget {
final String title; final String title;
final List<String> subtitleLines; final List<String> subtitleLines;
final String? avatarUrl; final String? avatarUrl;
@@ -21,7 +21,7 @@ class AdminUserCard extends StatefulWidget {
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding; final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({ const UserCard({
super.key, super.key,
required this.title, required this.title,
required this.subtitleLines, required this.subtitleLines,
@@ -41,10 +41,10 @@ class AdminUserCard extends StatefulWidget {
}); });
@override @override
State<AdminUserCard> createState() => _AdminUserCardState(); State<UserCard> createState() => _UserCardState();
} }
class _AdminUserCardState extends State<AdminUserCard> { class _UserCardState extends State<UserCard> {
bool _isHovered = false; bool _isHovered = false;
@override @override
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AdminListState extends StatelessWidget { class UserListState extends StatelessWidget {
final bool isLoading; final bool isLoading;
final String? error; final String? error;
final bool isEmpty; final bool isEmpty;
final String emptyMessage; final String emptyMessage;
final Widget list; final Widget list;
const AdminListState({ const UserListState({
super.key, super.key,
required this.isLoading, required this.isLoading,
required this.error, required this.error,
@@ -1,23 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.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/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/dossiers_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_managmant_widget.dart';
class UserManagementPanel extends StatefulWidget { class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs). /// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
final bool showAdministrateursTab; final bool showAdministrateursTab;
/// Création gestionnaire / admin (#161). False pour le dashboard gestionnaire.
final bool allowStaffAccountCreation;
const UserManagementPanel({ const UserManagementPanel({
super.key, super.key,
this.showAdministrateursTab = true, this.showAdministrateursTab = true,
this.allowStaffAccountCreation = true,
}); });
@override @override
@@ -86,6 +90,15 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
bool get _isDossiersTab => _subIndex == 0; bool get _isDossiersTab => _subIndex == 0;
bool get _isStaffAccountsTab =>
_subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5);
bool get _canShowAddButton {
if (_isDossiersTab) return false;
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false;
return true;
}
String _searchHintForTab() { String _searchHintForTab() {
switch (_subIndex) { switch (_subIndex) {
case 0: case 0:
@@ -293,7 +306,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
searchTooltip: _searchTooltipForTab(), searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(), filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153). // Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed, // Pas de création staff pour le dashboard gestionnaire (#161).
onAddPressed: _canShowAddButton ? _handleAddPressed : null,
addLabel: 'Ajouter', addLabel: 'Ajouter',
subTabCount: labels.length, subTabCount: labels.length,
tabLabels: labels, tabLabels: labels,
@@ -305,6 +319,9 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) {
return;
}
if (_subIndex == 1) { if (_subIndex == 1) {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@@ -331,7 +348,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AdminChildDetailModal.create( return ChildDetailModal.create(
onSaved: () { onSaved: () {
if (!mounted) return; if (!mounted) return;
setState(() => _enfantRefreshTick++); setState(() => _enfantRefreshTick++);
@@ -368,7 +385,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const AdminUserFormDialog(); return const StaffUserFormModal();
}, },
); );
@@ -386,7 +403,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const AdminUserFormDialog( return const StaffUserFormModal(
adminMode: true, adminMode: true,
withRelais: false, withRelais: false,
); );
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review]. /// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
class ValidationAmWizard extends StatelessWidget { class ValidationAmWizard extends StatelessWidget {
@@ -1,21 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.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/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; import 'package:p_tits_pas/widgets/dashboard/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119. /// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
class ValidationDossierModal extends StatefulWidget { class ValidationDossierModal extends StatefulWidget {
final String numeroDossier; final String numeroDossier;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback? onSuccess; final VoidCallback? onSuccess;
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
final bool openAsEdit;
const ValidationDossierModal({ const ValidationDossierModal({
super.key, super.key,
required this.numeroDossier, required this.numeroDossier,
required this.onClose, required this.onClose,
this.onSuccess, this.onSuccess,
this.openAsEdit = false,
}); });
@override @override
@@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620. /// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5 static const double _modalWidth = 930; // 620 * 1.5
static const double _familyBodyHeight = 435;
double get _bodyHeight { double get _bodyHeight {
final d = _dossier; final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
return _familyBodyHeight; // Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
return ParentDossierWizard.shellBodyHeight;
} }
@override @override
@@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
} }
final d = _dossier!; final d = _dossier!;
if (d.isAm) { if (d.isAm) {
if (widget.openAsEdit) {
return AmDossierWizard.edit(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationAmWizard( return ValidationAmWizard(
dossier: d.asAm, dossier: d.asAm,
onClose: widget.onClose, onClose: widget.onClose,
@@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
onStepChanged: _onStepChanged, onStepChanged: _onStepChanged,
); );
} }
if (widget.openAsEdit) {
return ParentDossierWizard.edit(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard( return ValidationFamilyWizard(
dossier: d.asFamily, dossier: d.asFamily,
onClose: widget.onClose, onClose: widget.onClose,

Some files were not shown because too many files have changed in this diff Show More