Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
514d19236d | ||
|
|
01fb514bde | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 |
@@ -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: [
|
||||||
|
|||||||
@@ -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 d’abord, puis n° décroissant.',
|
'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -157,9 +163,31 @@ export class EnfantsController {
|
|||||||
return this.enfantsService.update(id, dto, currentUser, photo);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { }
|
||||||
|
|||||||
@@ -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 d’enfants 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 d’utilisateur 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Matrice suppression — #154 / back **#159** / front **#160**
|
||||||
|
|
||||||
|
**Statut** : cadrage PO validé (sept. 2026)
|
||||||
|
**Milestone** : 0.1.0
|
||||||
|
**Email** : pas d’email 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 d’entré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).
|
||||||
@@ -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 d’impact) → DELETE → **refresh** liste
|
||||||
|
- Pending = **mêmes** règles que validés
|
||||||
|
- **Pas** d’email
|
||||||
|
|
||||||
|
| 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 : « L’enfant sera supprimé du dossier de {famille} ({n°}). »
|
||||||
|
- Dernier : proposer **deux actions** :
|
||||||
|
1. Supprimer l’enfant 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 s’appeler `Admin*`.
|
||||||
|
On garde `Admin*` seulement là où c’est 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 l’option 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 d’arborescence (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 d’imports (à 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 d’exé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 d’imports panels vers l’ancien 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 d’acceptation
|
||||||
|
|
||||||
|
- [ ] 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 d’autres 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 d’archive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
|
||||||
@@ -13,6 +13,10 @@ class DossierListItem {
|
|||||||
final String? statut;
|
final String? statut;
|
||||||
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
/// Dossier famille sans enfant lié (#159 / #160).
|
||||||
|
final bool sansEnfant;
|
||||||
|
/// Nombre d’enfants 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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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/admin/parametres_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
import 'package:p_tits_pas/widgets/app_footer.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/admin/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;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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/relais_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/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
class AdminUserFormDialog extends StatefulWidget {
|
class AdminUserFormDialog extends StatefulWidget {
|
||||||
final AppUser? initialUser;
|
final AppUser? initialUser;
|
||||||
@@ -43,6 +45,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
List<RelaisModel> _relais = [];
|
List<RelaisModel> _relais = [];
|
||||||
String? _selectedRelaisId;
|
String? _selectedRelaisId;
|
||||||
String? _currentUserId;
|
String? _currentUserId;
|
||||||
|
String? _currentUserRole;
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
bool get _isSuperAdminTarget =>
|
bool get _isSuperAdminTarget =>
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||||
@@ -50,7 +53,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
_isEditMode &&
|
_isEditMode &&
|
||||||
_currentUserId != null &&
|
_currentUserId != null &&
|
||||||
widget.initialUser!.id == _currentUserId;
|
widget.initialUser!.id == _currentUserId;
|
||||||
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
|
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
|
||||||
|
bool get _canDeleteTarget {
|
||||||
|
if (!_isEditMode || widget.readOnly) return false;
|
||||||
|
if (_isSelfTarget || _isSuperAdminTarget) return false;
|
||||||
|
return canDeleteGestionnaire(_currentUserRole);
|
||||||
|
}
|
||||||
bool get _isLockedAdminIdentity =>
|
bool get _isLockedAdminIdentity =>
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
String get _targetRoleKey {
|
String get _targetRoleKey {
|
||||||
@@ -125,6 +133,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserId = cached.id;
|
_currentUserId = cached.id;
|
||||||
|
_currentUserRole = cached.role;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserId = refreshed.id;
|
_currentUserId = refreshed.id;
|
||||||
|
_currentUserRole = refreshed.role;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,30 +388,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (!_canDeleteTarget) return;
|
if (!_canDeleteTarget) return;
|
||||||
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: widget.adminMode
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
? 'Supprimer l\'administrateur'
|
||||||
),
|
: 'Supprimer le gestionnaire',
|
||||||
actions: [
|
people: [
|
||||||
TextButton(
|
widget.adminMode
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
? SuppressionPersonLine.administrateur(name)
|
||||||
child: const Text('Annuler'),
|
: SuppressionPersonLine.gestionnaire(name),
|
||||||
),
|
],
|
||||||
FilledButton(
|
footnotes: const [
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
'Le compte sera définitivement supprimé.',
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -646,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).
|
||||||
@@ -1224,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 d’affichage 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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ 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/screens/administrateurs/creation/gestionnaires_create.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/dashboard/user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AdminManagementWidget extends StatefulWidget {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
@@ -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,6 +84,16 @@ 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>(
|
||||||
@@ -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 : c’est 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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ 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/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||||
@@ -221,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 d’agrément',
|
label: 'Date d’agré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()
|
||||||
@@ -741,14 +741,14 @@ 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;
|
||||||
|
|
||||||
@@ -762,7 +762,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
|
|
||||||
final Widget photo;
|
final Widget photo;
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
photo = AdminAmPhotoFrame(
|
photo = AmPhotoFrame(
|
||||||
imageBytes: _photoBytes,
|
imageBytes: _photoBytes,
|
||||||
onTap: _pickPhoto,
|
onTap: _pickPhoto,
|
||||||
onClear: _photoBytes != null ? _clearPhoto : null,
|
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
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/dashboard/user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
|
|
||||||
|
/// Choix pour le dernier enfant d’un dossier famille (#160).
|
||||||
|
enum DernierEnfantSuppressionChoice {
|
||||||
|
enfantSeul,
|
||||||
|
dossierAussi,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne d’impact (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 d’impact pour suppression d’un 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(
|
||||||
|
'L’enfant 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,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/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icô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 d’enfants (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,8 +1,11 @@
|
|||||||
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/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/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.
|
||||||
@@ -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);
|
||||||
@@ -95,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;
|
||||||
@@ -115,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);
|
||||||
@@ -189,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,
|
||||||
|
|||||||
@@ -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/utils/enfant_status_utils.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/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/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.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/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
||||||
@@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<EnfantAdminModel> _enfants = [];
|
List<EnfantAdminModel> _enfants = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadEnfants();
|
_loadEnfants();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEnfants() async {
|
Future<void> _loadEnfants() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -56,13 +68,136 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
Future<void> _openEnfant(EnfantAdminModel enfant) async {
|
Future<void> _openEnfant(EnfantAdminModel enfant) async {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminChildDetailModal(
|
builder: (ctx) => ChildDetailModal(
|
||||||
enfant: enfant,
|
enfant: enfant,
|
||||||
onSaved: _loadEnfants,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -89,7 +224,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final enfant = filtered[index];
|
final enfant = filtered[index];
|
||||||
return AdminEnfantUserCard.fromEnfant(
|
return EnfantUserCard.fromEnfant(
|
||||||
enfant,
|
enfant,
|
||||||
onCardTap: () => _openEnfant(enfant),
|
onCardTap: () => _openEnfant(enfant),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
tooltip: 'Voir / modifier',
|
tooltip: 'Voir / modifier',
|
||||||
onPressed: () => _openEnfant(enfant),
|
onPressed: () => _openEnfant(enfant),
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(
|
||||||
|
onPressed: () => _confirmDelete(enfant),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
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/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatefulWidget {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
@@ -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;
|
||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||||
@@ -860,7 +860,7 @@ 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
|
photoUrl: child.photoBytes == null
|
||||||
? child.existingPhotoUrl
|
? child.existingPhotoUrl
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,8 +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/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/dashboard/user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class ParentManagementWidget extends StatefulWidget {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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/admin/dossier_list_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/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.
|
||||||
@@ -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/admin/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,8 +3,8 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
|
|||||||
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/admin/am_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/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/admin/dossiers_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
@@ -15,9 +15,13 @@ 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 l’onglet Dossiers (#153).
|
// Pas de « Créer » sur l’onglet 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++);
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
+38
-51
@@ -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/admin/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/admin/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 : l’onglet 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 l’onglet 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(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
+2
-2
@@ -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,
|
||||||
+114
-44
@@ -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/dashboard/select_famille_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/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;
|
||||||
@@ -63,7 +66,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 +100,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;
|
||||||
|
|
||||||
@@ -283,7 +285,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(),
|
||||||
),
|
),
|
||||||
@@ -506,37 +508,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 +612,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 +628,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 +680,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 +990,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 +1005,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 +1333,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',
|
||||||
);
|
);
|
||||||
+4
-4
@@ -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: [
|
||||||
+5
-5
@@ -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,
|
||||||
+8
-8
@@ -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,
|
||||||
+13
-13
@@ -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/admin/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;
|
||||||
+10
-10
@@ -3,9 +3,9 @@ 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/admin/validation_modal_theme.dart';
|
||||||
|
|
||||||
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
||||||
@@ -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 {
|
||||||
+8
-8
@@ -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),
|
||||||
+12
-12
@@ -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: [
|
||||||
+10
-10
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/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;
|
||||||
+2
-2
@@ -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,
|
||||||
+4
-4
@@ -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
|
||||||
+2
-2
@@ -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,
|
||||||
Reference in New Issue
Block a user