diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff54b1e..4fb9b45 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module'; import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; import { AbsencesGardeModule } from './modules/absences-garde'; +import { CardsModule } from './modules/cards'; import { RelaisModule } from './routes/relais/relais.module'; import { DossiersModule } from './routes/dossiers/dossiers.module'; import { SuppressionsModule } from './routes/suppressions/suppressions.module'; @@ -58,6 +59,7 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module'; AppConfigModule, DocumentsLegauxModule, AbsencesGardeModule, + CardsModule, RelaisModule, DossiersModule, SuppressionsModule, diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index 65d46b8..24d57f8 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm'; +import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn, ManyToOne } from 'typeorm'; import { Users } from './users.entity'; import { AmChildren } from './am_children.entity'; @@ -37,22 +37,32 @@ export class AssistanteMaternelle { @Column({ name: 'ville_residence', length: 100, nullable: true }) residence_city?: string; - @Column( { name: 'date_agrement', type: 'date', nullable: true }) + @Column({ name: 'date_agrement', type: 'date', nullable: true }) agreement_date?: Date; - @Column( { name: 'annee_experience', type: 'smallint', nullable: true }) + @Column({ name: 'annee_experience', type: 'smallint', nullable: true }) years_experience?: number; - - @Column( { name: 'specialite', length: 100, nullable: true }) + + @Column({ name: 'specialite', length: 100, nullable: true }) specialty?: string; - @Column( { name: 'place_disponible', type: 'integer', nullable: true }) + @Column({ name: 'place_disponible', type: 'integer', nullable: true }) places_available?: number; /** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */ @Column({ name: 'numero_dossier', length: 20, nullable: true }) numero_dossier?: string; + /** + * Placement AM↔enfant sélectionné sur le TdB AM (couple actif) — ticket #171. + */ + @Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true }) + id_placement_garde_courant?: string; + + @ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' }) + placement_garde_courant?: AmChildren; + @OneToMany(() => AmChildren, (ac) => ac.am) amChildren: AmChildren[]; } diff --git a/backend/src/entities/card_audience_members.entity.ts b/backend/src/entities/card_audience_members.entity.ts new file mode 100644 index 0000000..a608301 --- /dev/null +++ b/backend/src/entities/card_audience_members.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CardInstance } from './card_instances.entity'; +import { Users } from './users.entity'; + +@Entity('card_audience_members', { schema: 'public' }) +export class CardAudienceMember { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_card', type: 'uuid' }) + id_card: string; + + @ManyToOne(() => CardInstance, (c) => c.audience, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) + card: CardInstance; + + @Column({ name: 'id_utilisateur', type: 'uuid' }) + id_utilisateur: string; + + @ManyToOne(() => Users, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) + user: Users; + + @Column({ name: 'role_snapshot', type: 'varchar', length: 64 }) + role_snapshot: string; + + @Column({ name: 'is_creator', type: 'boolean', default: false }) + is_creator: boolean; +} diff --git a/backend/src/entities/card_instances.entity.ts b/backend/src/entities/card_instances.entity.ts new file mode 100644 index 0000000..3fa92da --- /dev/null +++ b/backend/src/entities/card_instances.entity.ts @@ -0,0 +1,97 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AmChildren } from './am_children.entity'; +import { AbsencesGarde } from './absences_garde.entity'; +import { Users } from './users.entity'; +import { CardType } from './card_types.entity'; +import { CardAudienceMember } from './card_audience_members.entity'; +import { CardResponse } from './card_responses.entity'; + +export enum CardInstanceStatutType { + OUVERTE = 'ouverte', + REFUSEE = 'refusee', + TRAITEE = 'traitee', +} + +export enum CardOperationType { + CREATE = 'create', + UPDATE = 'update', +} + +@Entity('card_instances', { schema: 'public' }) +export class CardInstance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'type_code', type: 'varchar', length: 64 }) + type_code: string; + + @ManyToOne(() => CardType, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'type_code', referencedColumnName: 'code' }) + type: CardType; + + @Column({ name: 'id_placement', type: 'uuid' }) + id_placement: string; + + @ManyToOne(() => AmChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_placement', referencedColumnName: 'id' }) + placement: AmChildren; + + @Column({ name: 'id_absence', type: 'uuid', nullable: true }) + id_absence?: string; + + @ManyToOne(() => AbsencesGarde, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'id_absence', referencedColumnName: 'id' }) + absence?: AbsencesGarde; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; + + @Column({ + type: 'enum', + enum: CardOperationType, + enumName: 'card_operation_type', + name: 'operation', + default: CardOperationType.CREATE, + }) + operation: CardOperationType; + + @Column({ + type: 'enum', + enum: CardInstanceStatutType, + enumName: 'card_instance_statut_type', + name: 'statut', + default: CardInstanceStatutType.OUVERTE, + }) + statut: CardInstanceStatutType; + + @Column({ name: 'payload', type: 'jsonb', default: {} }) + payload: Record; + + @Column({ name: 'purge_at', type: 'timestamptz' }) + purge_at: Date; + + @OneToMany(() => CardAudienceMember, (m) => m.card) + audience: CardAudienceMember[]; + + @OneToMany(() => CardResponse, (r) => r.card) + responses: CardResponse[]; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; +} diff --git a/backend/src/entities/card_responses.entity.ts b/backend/src/entities/card_responses.entity.ts new file mode 100644 index 0000000..f278502 --- /dev/null +++ b/backend/src/entities/card_responses.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CardInstance } from './card_instances.entity'; +import { Users } from './users.entity'; + +export enum CardResponseActionType { + ACCEPT = 'accept', + REFUSE = 'refuse', + ACK = 'ack', +} + +@Entity('card_responses', { schema: 'public' }) +export class CardResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_card', type: 'uuid' }) + id_card: string; + + @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) + card: CardInstance; + + @Column({ name: 'id_utilisateur', type: 'uuid' }) + id_utilisateur: string; + + @ManyToOne(() => Users, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) + user: Users; + + @Column({ + type: 'enum', + enum: CardResponseActionType, + enumName: 'card_response_action_type', + name: 'action', + }) + action: CardResponseActionType; + + @Column({ name: 'comment', type: 'text', nullable: true }) + comment?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; +} diff --git a/backend/src/entities/card_types.entity.ts b/backend/src/entities/card_types.entity.ts new file mode 100644 index 0000000..d57a800 --- /dev/null +++ b/backend/src/entities/card_types.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum CardResponseModeType { + NONE = 'none', + ACK = 'ack', + ACCEPT_REFUSE = 'accept_refuse', +} + +@Entity('card_types', { schema: 'public' }) +export class CardType { + @PrimaryColumn({ name: 'code', type: 'varchar', length: 64 }) + code: string; + + @Column({ name: 'system', type: 'boolean', default: true }) + system: boolean; + + @Column({ name: 'titre', type: 'varchar', length: 120 }) + titre: string; + + @Column({ name: 'emitter_roles', type: 'text', array: true }) + emitter_roles: string[]; + + @Column({ name: 'recipient_roles', type: 'text', array: true }) + recipient_roles: string[]; + + @Column({ name: 'audience_resolver', type: 'varchar', length: 64 }) + audience_resolver: string; + + @Column({ + type: 'enum', + enum: CardResponseModeType, + enumName: 'card_response_mode_type', + name: 'response_mode', + }) + response_mode: CardResponseModeType; + + @Column({ name: 'retention_days', type: 'int', default: 14 }) + retention_days: number; + + @Column({ name: 'couleur', type: 'varchar', length: 32, nullable: true }) + couleur?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; +} diff --git a/backend/src/modules/absences-garde/absences-garde.module.ts b/backend/src/modules/absences-garde/absences-garde.module.ts index 174fa37..26480e0 100644 --- a/backend/src/modules/absences-garde/absences-garde.module.ts +++ b/backend/src/modules/absences-garde/absences-garde.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AbsencesGarde } from 'src/entities/absences_garde.entity'; import { AmChildren } from 'src/entities/am_children.entity'; @@ -9,6 +11,14 @@ import { AbsencesGardeService } from './absences-garde.service'; @Module({ imports: [ TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), ], controllers: [AbsencesGardeController], providers: [AbsencesGardeService], diff --git a/backend/src/modules/absences-garde/absences-garde.service.ts b/backend/src/modules/absences-garde/absences-garde.service.ts index a3bf9f5..dd251ae 100644 --- a/backend/src/modules/absences-garde/absences-garde.service.ts +++ b/backend/src/modules/absences-garde/absences-garde.service.ts @@ -312,15 +312,16 @@ export class AbsencesGardeService { ); } if (role === RoleType.ASSISTANTE_MATERNELLE) { - // Remise en attente après refus (republication) + // Remise en attente après refus OU modification d’un congé déjà accepté (S2b) if ( - row.statut === StatutAbsenceGardeType.REFUSE && - next === StatutAbsenceGardeType.EN_ATTENTE + next === StatutAbsenceGardeType.EN_ATTENTE && + (row.statut === StatutAbsenceGardeType.REFUSE || + row.statut === StatutAbsenceGardeType.ACCEPTE) ) { return; } throw new ForbiddenException( - 'L’AM ne valide pas elle-même (sauf republication après refus)', + 'L’AM ne valide pas elle-même (sauf republication / modification)', ); } throw new ForbiddenException('Changement de statut non autorisé'); diff --git a/backend/src/modules/cards/cards-realtime.controller.ts b/backend/src/modules/cards/cards-realtime.controller.ts new file mode 100644 index 0000000..3d162ef --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + Get, + Headers, + MessageEvent, + Query, + Sse, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Observable } from 'rxjs'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { Public } from 'src/common/decorators/public.decorator'; +import { User } from 'src/common/decorators/user.decorator'; +import { RoleType } from 'src/entities/users.entity'; +import { CardsRealtimeService } from './cards-realtime.service'; + +/** + * SSE Cartes — #195 + * Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur). + */ +@ApiTags('Cartes') +@Controller('cards') +export class CardsRealtimeController { + constructor( + private readonly realtime: CardsRealtimeService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + @Sse('stream') + @Public() + @ApiOperation({ + summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195', + }) + @ApiQuery({ + name: 'access_token', + required: false, + description: 'JWT si pas de header Authorization (EventSource)', + }) + @ApiBearerAuth('access-token') + async stream( + @Headers('authorization') authorization?: string, + @Query('access_token') accessToken?: string, + ): Promise> { + const userId = await this.resolveUserId(authorization, accessToken); + return this.realtime.streamFor(userId); + } + + /** Variante gardée (clients qui envoient Bearer correctement). */ + @Get('stream/info') + @UseGuards(AuthGuard, RolesGuard) + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' }) + info(@User('id') userId: string) { + return { + user_id: userId, + subscribers: this.realtime.subscriberCount(userId), + }; + } + + private async resolveUserId( + authorization?: string, + accessToken?: string, + ): Promise { + let token = accessToken?.trim(); + if (!token && authorization?.startsWith('Bearer ')) { + token = authorization.slice(7).trim(); + } + if (!token) { + throw new UnauthorizedException('Token manquant (Bearer ou access_token)'); + } + try { + const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, { + secret: this.configService.get('jwt.accessSecret'), + }); + if (!payload?.sub) throw new UnauthorizedException('Token invalide'); + return payload.sub; + } catch { + throw new UnauthorizedException('Token invalide ou expiré'); + } + } +} diff --git a/backend/src/modules/cards/cards-realtime.service.spec.ts b/backend/src/modules/cards/cards-realtime.service.spec.ts new file mode 100644 index 0000000..3179587 --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.service.spec.ts @@ -0,0 +1,47 @@ +import { MessageEvent } from '@nestjs/common'; +import { CardsRealtimeService } from './cards-realtime.service'; + +describe('CardsRealtimeService (#195)', () => { + let service: CardsRealtimeService; + + beforeEach(() => { + service = new CardsRealtimeService(); + }); + + afterEach(() => { + service.onModuleDestroy(); + }); + + it('émet card.created aux abonnés du user', async () => { + const events: MessageEvent[] = []; + const sub = service.streamFor('user-a').subscribe((e) => events.push(e)); + + await new Promise((r) => setTimeout(r, 20)); + expect(events[0]?.type).toBe('heartbeat'); + expect(service.subscriberCount('user-a')).toBe(1); + + service.emitToUsers(['user-a', 'user-b'], 'card.created', 'card-1', { + id: 'card-1', + }); + await new Promise((r) => setTimeout(r, 20)); + + const created = events.find((e) => e.type === 'card.created'); + expect(created).toBeDefined(); + expect((created!.data as { card_id: string }).card_id).toBe('card-1'); + expect(service.subscriberCount('user-b')).toBe(0); + + sub.unsubscribe(); + expect(service.subscriberCount('user-a')).toBe(0); + }); + + it('ne diffuse pas aux users non abonnés', async () => { + const events: MessageEvent[] = []; + const sub = service.streamFor('user-a').subscribe((e) => events.push(e)); + await new Promise((r) => setTimeout(r, 20)); + const before = events.length; + service.emitToUsers(['user-b'], 'card.updated', 'x'); + await new Promise((r) => setTimeout(r, 20)); + expect(events.length).toBe(before); + sub.unsubscribe(); + }); +}); diff --git a/backend/src/modules/cards/cards-realtime.service.ts b/backend/src/modules/cards/cards-realtime.service.ts new file mode 100644 index 0000000..aa6e434 --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.service.ts @@ -0,0 +1,111 @@ +import { Injectable, MessageEvent, OnModuleDestroy } from '@nestjs/common'; +import { Observable, Subject, interval, merge, takeUntil } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export type CardRealtimeEventType = + | 'card.created' + | 'card.updated' + | 'card.deleted' + | 'response.added' + | 'heartbeat'; + +export interface CardRealtimePayload { + event: CardRealtimeEventType; + card_id?: string; + data?: unknown; + at: string; +} + +/** + * Bus SSE in-memory par utilisateur (V1 mono-instance). + * Rooms = userId (audience carte). + */ +@Injectable() +export class CardsRealtimeService implements OnModuleDestroy { + private readonly byUser = new Map>>(); + private readonly destroy$ = new Subject(); + + onModuleDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + for (const set of this.byUser.values()) { + for (const s of set) s.complete(); + } + this.byUser.clear(); + } + + /** Flux SSE pour un utilisateur authentifié. */ + streamFor(userId: string): Observable { + const subject = new Subject(); + let set = this.byUser.get(userId); + if (!set) { + set = new Set(); + this.byUser.set(userId, set); + } + set.add(subject); + + const heartbeat$ = interval(25_000).pipe( + map( + (): CardRealtimePayload => ({ + event: 'heartbeat', + at: new Date().toISOString(), + }), + ), + ); + + return new Observable((observer) => { + const sub = merge(subject, heartbeat$) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (payload) => + observer.next({ + type: payload.event, + data: payload, + } as MessageEvent), + error: (err) => observer.error(err), + complete: () => observer.complete(), + }); + + // ping initial + subject.next({ + event: 'heartbeat', + at: new Date().toISOString(), + }); + + return () => { + sub.unsubscribe(); + set!.delete(subject); + subject.complete(); + if (set!.size === 0) this.byUser.delete(userId); + }; + }); + } + + emitToUsers( + userIds: string[], + event: Exclude, + cardId: string | undefined, + data?: unknown, + ): void { + const unique = [...new Set(userIds.filter(Boolean))]; + const payload: CardRealtimePayload = { + event, + card_id: cardId, + data, + at: new Date().toISOString(), + }; + for (const uid of unique) { + const set = this.byUser.get(uid); + if (!set) continue; + for (const s of set) s.next(payload); + } + } + + /** Test / debug : nombre d’abonnés actifs. */ + subscriberCount(userId?: string): number { + if (userId) return this.byUser.get(userId)?.size ?? 0; + let n = 0; + for (const s of this.byUser.values()) n += s.size; + return n; + } +} diff --git a/backend/src/modules/cards/cards.controller.ts b/backend/src/modules/cards/cards.controller.ts new file mode 100644 index 0000000..1b8ce80 --- /dev/null +++ b/backend/src/modules/cards/cards.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { User } from 'src/common/decorators/user.decorator'; +import { RoleType } from 'src/entities/users.entity'; +import { CardsService } from './cards.service'; +import { + CarteDto, + CreerCarteDto, + ListeCartesDto, + MajCarteDto, + RepondreCarteDto, +} from './dto/cards.dto'; + +@ApiTags('Cartes') +@ApiBearerAuth('access-token') +@Controller('cards') +@UseGuards(AuthGuard, RolesGuard) +export class CardsController { + constructor(private readonly cardsService: CardsService) {} + + @Get('types') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Types SYSTEM émissibles pour mon rôle — #194' }) + listerTypes(@User('role') role: RoleType) { + return this.cardsService.listerTypes(role); + } + + @Get() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Feed bulles de l’utilisateur — #194' }) + @ApiQuery({ name: 'placementId', required: false }) + @ApiResponse({ status: 200, type: ListeCartesDto }) + lister( + @User('id') userId: string, + @User('role') role: RoleType, + @Query('placementId') placementId?: string, + ): Promise { + return this.cardsService.lister(userId, role, placementId); + } + + @Post() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer une bulle (+ hook absences_garde) — #194', + }) + @ApiBody({ type: CreerCarteDto }) + @ApiResponse({ status: 201, type: CarteDto }) + creer( + @User('id') userId: string, + @User('role') role: RoleType, + @Body() dto: CreerCarteDto, + ): Promise { + return this.cardsService.creer(userId, role, dto); + } + + @Post(':id/respond') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Répondre (accept / refuse / ack) — #194' }) + @ApiBody({ type: RepondreCarteDto }) + repondre( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RepondreCarteDto, + ): Promise { + return this.cardsService.repondre(userId, role, id, dto); + } + + @Patch(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Modifier dates (créateur, ouverte/refusée) — #194', + }) + @ApiBody({ type: MajCarteDto }) + maj( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MajCarteDto, + ): Promise { + return this.cardsService.majEnAttente(userId, role, id, dto); + } + + @Delete(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Supprimer bulle (+ absence si non traitée) — #194' }) + async supprimer( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.cardsService.supprimer(userId, role, id); + } +} diff --git a/backend/src/modules/cards/cards.module.ts b/backend/src/modules/cards/cards.module.ts new file mode 100644 index 0000000..fd418ab --- /dev/null +++ b/backend/src/modules/cards/cards.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AbsencesGardeModule } from '../absences-garde'; +import { CardType } from 'src/entities/card_types.entity'; +import { CardInstance } from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { CardResponse } from 'src/entities/card_responses.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { CardsController } from './cards.controller'; +import { CardsRealtimeController } from './cards-realtime.controller'; +import { CardsService } from './cards.service'; +import { CardsRealtimeService } from './cards-realtime.service'; + +@Module({ + imports: [ + AbsencesGardeModule, + TypeOrmModule.forFeature([ + CardType, + CardInstance, + CardAudienceMember, + CardResponse, + AmChildren, + ParentsChildren, + ]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), + ], + controllers: [CardsController, CardsRealtimeController], + providers: [CardsService, CardsRealtimeService], + exports: [CardsService, CardsRealtimeService], +}) +export class CardsModule {} diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts new file mode 100644 index 0000000..ff621f5 --- /dev/null +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -0,0 +1,160 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ForbiddenException } from '@nestjs/common'; +import { CardsService } from './cards.service'; +import { CardsRealtimeService } from './cards-realtime.service'; +import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; +import { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; +import { + CardInstance, + CardInstanceStatutType, + CardOperationType, +} from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { CardResponse } from 'src/entities/card_responses.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { StatutAbsenceGardeType, TypeAbsenceGardeType } from 'src/entities/absences_garde.entity'; + +describe('CardsService (#194)', () => { + let service: CardsService; + + const typesRepo = { find: jest.fn(), findOne: jest.fn() }; + const cardsRepo = { + create: jest.fn((x) => x), + save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + manager: { query: jest.fn() }, + }; + const audienceRepo = { + create: jest.fn((x) => x), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + const responsesRepo = { + create: jest.fn((x) => x), + save: jest.fn(), + }; + const amChildrenRepo = { findOne: jest.fn() }; + const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; + const absencesService = { + creer: jest.fn(), + maj: jest.fn(), + supprimer: jest.fn(), + }; + const realtime = { emitToUsers: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CardsService, + { provide: getRepositoryToken(CardType), useValue: typesRepo }, + { provide: getRepositoryToken(CardInstance), useValue: cardsRepo }, + { provide: getRepositoryToken(CardAudienceMember), useValue: audienceRepo }, + { provide: getRepositoryToken(CardResponse), useValue: responsesRepo }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, + { provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo }, + { provide: AbsencesGardeService, useValue: absencesService }, + { provide: CardsRealtimeService, useValue: realtime }, + ], + }).compile(); + service = module.get(CardsService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('AM crée congé → absence + carte ouverte + audience parents', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'conge_am', + system: true, + titre: 'Congé AM', + emitter_roles: [RoleType.ASSISTANTE_MATERNELLE], + recipient_roles: [RoleType.PARENT], + audience_resolver: 'couple_parents', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + couleur: 'lavender', + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + absencesService.creer.mockResolvedValue({ + id: 'abs-1', + type: TypeAbsenceGardeType.CONGE_AM, + statut: StatutAbsenceGardeType.EN_ATTENTE, + }); + parentsChildrenRepo.find.mockResolvedValue([ + { parentId: 'p-1', enfantId: 'e-1' }, + { parentId: 'p-2', enfantId: 'e-1' }, + ]); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-1', + type_code: 'conge_am', + id_placement: 'pl-1', + id_absence: 'abs-1', + cree_par: 'am-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-11-01', date_fin: '2026-11-07' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Congé AM', + couleur: 'lavender', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + }, + responses: [], + audience: [ + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + + const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, { + type_code: 'conge_am', + id_placement: 'pl-1', + date_debut: '2026-11-01', + date_fin: '2026-11-07', + }); + + expect(absencesService.creer).toHaveBeenCalled(); + expect(audienceRepo.save).toHaveBeenCalled(); + expect(realtime.emitToUsers).toHaveBeenCalledWith( + ['am-1', 'p-1', 'p-2'], + 'card.created', + 'card-1', + expect.any(Object), + ); + expect(res.type_code).toBe('conge_am'); + expect(res.statut).toBe(CardInstanceStatutType.OUVERTE); + }); + + it('parent ne peut pas émettre conge_am', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'conge_am', + system: true, + emitter_roles: [RoleType.ASSISTANTE_MATERNELLE], + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + type_code: 'conge_am', + id_placement: 'pl-1', + date_debut: '2026-11-01', + date_fin: '2026-11-07', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts new file mode 100644 index 0000000..750b68c --- /dev/null +++ b/backend/src/modules/cards/cards.service.ts @@ -0,0 +1,489 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; +import { + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; +import { + CardInstance, + CardInstanceStatutType, + CardOperationType, +} from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { + CardResponse, + CardResponseActionType, +} from 'src/entities/card_responses.entity'; +import { + CarteDto, + CreerCarteDto, + ListeCartesDto, + MajCarteDto, + RepondreCarteDto, +} from './dto/cards.dto'; +import { CardsRealtimeService } from './cards-realtime.service'; + +@Injectable() +export class CardsService { + constructor( + @InjectRepository(CardType) + private readonly typesRepo: Repository, + @InjectRepository(CardInstance) + private readonly cardsRepo: Repository, + @InjectRepository(CardAudienceMember) + private readonly audienceRepo: Repository, + @InjectRepository(CardResponse) + private readonly responsesRepo: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepo: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepo: Repository, + private readonly absencesService: AbsencesGardeService, + private readonly realtime: CardsRealtimeService, + ) {} + + async listerTypes(role: RoleType): Promise { + const all = await this.typesRepo.find({ where: { system: true } }); + return all.filter((t) => t.emitter_roles.includes(role)); + } + + async lister( + userId: string, + role: RoleType, + placementId?: string, + ): Promise { + const qb = this.cardsRepo + .createQueryBuilder('c') + .innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId }) + .leftJoinAndSelect('c.type', 'type') + .leftJoinAndSelect('c.responses', 'responses') + .where('c.purge_at > now()') + .orderBy( + `CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`, + 'ASC', + ) + .addOrderBy('c.modifie_le', 'DESC'); + + if (placementId) { + await this.assertPlacementAccess(userId, role, placementId); + qb.andWhere('c.id_placement = :placementId', { placementId }); + } + + const rows = await qb.getMany(); + return { + items: rows.map((c) => this.toDto(c, userId)), + }; + } + + async creer( + userId: string, + role: RoleType, + dto: CreerCarteDto, + ): Promise { + if (dto.date_fin < dto.date_debut) { + throw new BadRequestException('date_fin < date_debut'); + } + + const type = await this.typesRepo.findOne({ where: { code: dto.type_code } }); + if (!type || !type.system) { + throw new NotFoundException('Type de carte inconnu'); + } + if (!type.emitter_roles.includes(role)) { + throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte'); + } + + const placement = await this.assertPlacementAccess( + userId, + role, + dto.id_placement, + ); + const operation = dto.operation ?? CardOperationType.CREATE; + + let absenceId = dto.id_absence; + const absenceType = this.mapAbsenceType(dto.type_code); + + if (operation === CardOperationType.CREATE) { + const absence = await this.absencesService.creer(userId, role, { + id_placement: dto.id_placement, + type: absenceType, + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif, + }); + absenceId = absence.id; + } else { + if (!absenceId) { + throw new BadRequestException('id_absence requis pour operation=update'); + } + await this.absencesService.maj(userId, role, absenceId, { + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif, + // Congé accepté modifié → repasse en attente via cartes respond flow; + // pour absence enfant update immédiat déjà fait. + ...(dto.type_code === 'conge_am' + ? { statut: StatutAbsenceGardeType.EN_ATTENTE } + : {}), + }); + } + + const statutInitial = + type.response_mode === CardResponseModeType.NONE + ? CardInstanceStatutType.TRAITEE + : CardInstanceStatutType.OUVERTE; + + const card = this.cardsRepo.create({ + type_code: type.code, + id_placement: dto.id_placement, + id_absence: absenceId, + cree_par: userId, + operation, + statut: statutInitial, + payload: { + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif ?? null, + }, + purge_at: this.purgeAt(type.retention_days, statutInitial), + }); + const saved = await this.cardsRepo.save(card); + + if (absenceId) { + await this.linkAbsenceCard(absenceId, saved.id); + } + + await this.buildAudience(saved, type, placement, userId, role); + + const full = await this.loadCard(saved.id); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'card.created', dtoOut); + return dtoOut; + } + + async repondre( + userId: string, + role: RoleType, + cardId: string, + dto: RepondreCarteDto, + ): Promise { + const card = await this.loadCard(cardId); + await this.assertInAudience(card, userId); + if (card.statut !== CardInstanceStatutType.OUVERTE) { + throw new BadRequestException('Cette carte n’est plus ouverte'); + } + if (card.cree_par === userId) { + throw new ForbiddenException('Le créateur ne répond pas à sa propre carte'); + } + + const mode = card.type.response_mode; + if (mode === CardResponseModeType.NONE) { + throw new BadRequestException('Ce type de carte ne demande pas de réponse'); + } + if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) { + throw new BadRequestException('Action attendue : ack'); + } + if ( + mode === CardResponseModeType.ACCEPT_REFUSE && + dto.action !== CardResponseActionType.ACCEPT && + dto.action !== CardResponseActionType.REFUSE + ) { + throw new BadRequestException('Action attendue : accept ou refuse'); + } + if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) { + throw new BadRequestException('Motivation obligatoire en cas de refus'); + } + + await this.responsesRepo.save( + this.responsesRepo.create({ + id_card: card.id, + id_utilisateur: userId, + action: dto.action, + comment: dto.comment?.trim(), + }), + ); + + if (card.id_absence) { + if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { + await this.absencesService.maj(userId, role, card.id_absence, { + statut: StatutAbsenceGardeType.ACCEPTE, + }); + card.statut = CardInstanceStatutType.TRAITEE; + } else if (dto.action === CardResponseActionType.REFUSE) { + await this.absencesService.maj(userId, role, card.id_absence, { + statut: StatutAbsenceGardeType.REFUSE, + motif: dto.comment!.trim(), + }); + card.statut = CardInstanceStatutType.REFUSEE; + } + } else if (dto.action === CardResponseActionType.ACK) { + card.statut = CardInstanceStatutType.TRAITEE; + } + + card.purge_at = this.purgeAt(card.type.retention_days, card.statut); + await this.cardsRepo.save(card); + const full = await this.loadCard(cardId); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'response.added', { + action: dto.action, + card: dtoOut, + }); + this.emitAudience(full, 'card.updated', dtoOut); + return dtoOut; + } + + async majEnAttente( + userId: string, + role: RoleType, + cardId: string, + dto: MajCarteDto, + ): Promise { + const card = await this.loadCard(cardId); + if (card.cree_par !== userId) { + throw new ForbiddenException('Seul le créateur peut modifier cette carte'); + } + if ( + card.statut !== CardInstanceStatutType.OUVERTE && + card.statut !== CardInstanceStatutType.REFUSEE + ) { + throw new BadRequestException('Carte non modifiable dans cet état'); + } + + const debut = + dto.date_debut ?? String(card.payload?.['date_debut'] ?? ''); + const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? ''); + if (!debut || !fin || fin < debut) { + throw new BadRequestException('Dates invalides'); + } + + if (card.id_absence) { + await this.absencesService.maj(userId, role, card.id_absence, { + date_debut: debut, + date_fin: fin, + motif: dto.motif, + statut: StatutAbsenceGardeType.EN_ATTENTE, + }); + } + + card.payload = { + ...card.payload, + date_debut: debut, + date_fin: fin, + motif: dto.motif ?? card.payload?.['motif'] ?? null, + }; + card.statut = CardInstanceStatutType.OUVERTE; + card.purge_at = this.purgeAt(card.type.retention_days, card.statut); + await this.cardsRepo.save(card); + const full = await this.loadCard(cardId); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'card.updated', dtoOut); + return dtoOut; + } + + async supprimer( + userId: string, + role: RoleType, + cardId: string, + ): Promise { + const card = await this.loadCard(cardId); + if (card.cree_par !== userId) { + throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); + } + const audienceIds = await this.audienceUserIds(card); + if (card.id_absence) { + const absStatut = + card.statut === CardInstanceStatutType.TRAITEE + ? null + : card.id_absence; + // Supprime l’absence liée si pas encore acceptée définitivement + if ( + card.statut === CardInstanceStatutType.OUVERTE || + card.statut === CardInstanceStatutType.REFUSEE + ) { + await this.absencesService.supprimer(userId, role, card.id_absence); + } + void absStatut; + } + await this.cardsRepo.delete({ id: cardId }); + this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, { + id: cardId, + }); + } + + private emitAudience( + card: CardInstance, + event: 'card.created' | 'card.updated' | 'response.added', + data: unknown, + ): void { + const ids = (card.audience ?? []).map((a) => a.id_utilisateur); + if (ids.length === 0) return; + this.realtime.emitToUsers(ids, event, card.id, data); + } + + private async audienceUserIds(card: CardInstance): Promise { + if (card.audience?.length) { + return card.audience.map((a) => a.id_utilisateur); + } + const rows = await this.audienceRepo.find({ where: { id_card: card.id } }); + return rows.map((r) => r.id_utilisateur); + } + + private mapAbsenceType(typeCode: string): TypeAbsenceGardeType { + if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') { + return TypeAbsenceGardeType.ABSENCE_ENFANT; + } + if (typeCode === 'conge_am') return TypeAbsenceGardeType.CONGE_AM; + if (typeCode === 'arret_maladie_am') { + return TypeAbsenceGardeType.ARRET_MALADIE_AM; + } + throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`); + } + + private async linkAbsenceCard(absenceId: string, cardId: string): Promise { + // Update direct pour éviter droits maj vides + await this.cardsRepo.manager.query( + `UPDATE absences_garde SET id_card_instance = $1, modifie_le = now() WHERE id = $2`, + [cardId, absenceId], + ); + } + + private async buildAudience( + card: CardInstance, + type: CardType, + placement: AmChildren, + creatorId: string, + creatorRole: RoleType, + ): Promise { + const members: Partial[] = [ + { + id_card: card.id, + id_utilisateur: creatorId, + role_snapshot: creatorRole, + is_creator: true, + }, + ]; + + if (type.audience_resolver === 'couple_am') { + members.push({ + id_card: card.id, + id_utilisateur: placement.amId, + role_snapshot: RoleType.ASSISTANTE_MATERNELLE, + is_creator: false, + }); + } else if (type.audience_resolver === 'couple_parents') { + const liens = await this.parentsChildrenRepo.find({ + where: { enfantId: placement.enfantId }, + }); + for (const l of liens) { + if (l.parentId === creatorId) continue; + members.push({ + id_card: card.id, + id_utilisateur: l.parentId, + role_snapshot: RoleType.PARENT, + is_creator: false, + }); + } + } + + // dédup + const seen = new Set(); + const unique = members.filter((m) => { + const k = m.id_utilisateur!; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + await this.audienceRepo.save(this.audienceRepo.create(unique)); + } + + private purgeAt( + retentionDays: number, + statut: CardInstanceStatutType, + ): Date { + const days = + statut === CardInstanceStatutType.OUVERTE + ? Math.max(retentionDays, 15) + : retentionDays; + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + private async loadCard(id: string): Promise { + const card = await this.cardsRepo.findOne({ + where: { id }, + relations: ['type', 'responses', 'audience'], + }); + if (!card) throw new NotFoundException('Carte introuvable'); + return card; + } + + private async assertInAudience(card: CardInstance, userId: string): Promise { + const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId); + if (!ok) { + const row = await this.audienceRepo.findOne({ + where: { id_card: card.id, id_utilisateur: userId }, + }); + if (!row) throw new ForbiddenException('Carte hors de votre audience'); + } + } + + private async assertPlacementAccess( + userId: string, + role: RoleType, + placementId: string, + ): Promise { + const placement = await this.amChildrenRepo.findOne({ + where: { id: placementId, date_fin: IsNull() }, + }); + if (!placement) { + throw new NotFoundException('Placement introuvable ou inactif'); + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if (placement.amId !== userId) { + throw new ForbiddenException('Placement non autorisé'); + } + return placement; + } + if (role === RoleType.PARENT) { + const lien = await this.parentsChildrenRepo.findOne({ + where: { parentId: userId, enfantId: placement.enfantId }, + }); + if (!lien) throw new ForbiddenException('Placement non autorisé'); + return placement; + } + throw new ForbiddenException('Rôle non autorisé'); + } + + private toDto(card: CardInstance, userId: string): CarteDto { + const lastRefuse = [...(card.responses ?? [])] + .reverse() + .find((r) => r.action === CardResponseActionType.REFUSE); + return { + id: card.id, + type_code: card.type_code, + titre: card.type?.titre ?? card.type_code, + couleur: card.type?.couleur ?? null, + id_placement: card.id_placement, + id_absence: card.id_absence ?? null, + operation: card.operation, + statut: card.statut, + payload: card.payload ?? {}, + purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at), + cree_par: card.cree_par ?? null, + is_creator: card.cree_par === userId, + response_mode: card.type?.response_mode, + last_refuse_comment: lastRefuse?.comment ?? null, + cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le), + modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le), + }; + } +} diff --git a/backend/src/modules/cards/dto/cards.dto.ts b/backend/src/modules/cards/dto/cards.dto.ts new file mode 100644 index 0000000..d2fc18b --- /dev/null +++ b/backend/src/modules/cards/dto/cards.dto.ts @@ -0,0 +1,137 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; +import { CardInstanceStatutType, CardOperationType } from 'src/entities/card_instances.entity'; +import { CardResponseActionType } from 'src/entities/card_responses.entity'; + +export class CreerCarteDto { + @ApiProperty({ + description: 'absence_enfant | absence_enfant_modif | conge_am | arret_maladie_am', + }) + @IsString() + type_code: string; + + @ApiProperty() + @IsUUID() + id_placement: string; + + @ApiProperty({ example: '2026-10-01' }) + @IsDateString() + date_debut: string; + + @ApiProperty({ example: '2026-10-05' }) + @IsDateString() + date_fin: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + motif?: string; + + @ApiPropertyOptional({ + description: 'Requis pour operation=update (même id absences_garde)', + }) + @IsOptional() + @IsUUID() + id_absence?: string; + + @ApiPropertyOptional({ enum: CardOperationType, default: CardOperationType.CREATE }) + @IsOptional() + @IsEnum(CardOperationType) + operation?: CardOperationType; +} + +export class RepondreCarteDto { + @ApiProperty({ enum: CardResponseActionType }) + @IsEnum(CardResponseActionType) + action: CardResponseActionType; + + @ApiPropertyOptional({ description: 'Obligatoire si action=refuse' }) + @ValidateIf((o) => o.action === CardResponseActionType.REFUSE) + @IsString() + @MinLength(1) + @MaxLength(2000) + comment?: string; +} + +export class MajCarteDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + date_debut?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + date_fin?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + motif?: string; +} + +export class CarteDto { + @ApiProperty() + id: string; + + @ApiProperty() + type_code: string; + + @ApiProperty() + titre: string; + + @ApiPropertyOptional() + couleur?: string | null; + + @ApiProperty() + id_placement: string; + + @ApiPropertyOptional() + id_absence?: string | null; + + @ApiProperty({ enum: CardOperationType }) + operation: CardOperationType; + + @ApiProperty({ enum: CardInstanceStatutType }) + statut: CardInstanceStatutType; + + @ApiProperty() + payload: Record; + + @ApiProperty() + purge_at: string; + + @ApiPropertyOptional() + cree_par?: string | null; + + @ApiProperty() + is_creator: boolean; + + @ApiPropertyOptional() + response_mode?: string; + + @ApiPropertyOptional() + last_refuse_comment?: string | null; + + @ApiProperty() + cree_le: string; + + @ApiProperty() + modifie_le: string; +} + +export class ListeCartesDto { + @ApiProperty({ type: [CarteDto] }) + items: CarteDto[]; +} diff --git a/backend/src/modules/cards/index.ts b/backend/src/modules/cards/index.ts new file mode 100644 index 0000000..c343ce4 --- /dev/null +++ b/backend/src/modules/cards/index.ts @@ -0,0 +1,3 @@ +export { CardsModule } from './cards.module'; +export { CardsService } from './cards.service'; +export { CardsRealtimeService } from './cards-realtime.service'; diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts index 5a7322d..0216da3 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts @@ -10,7 +10,10 @@ describe('AssistantesMaternellesController', () => { const authServiceMock = { createAmDossierStaff: jest.fn(), }; - const amServiceMock = {}; + const amServiceMock = { + listerCouplesGarde: jest.fn(), + definirCoupleGardeCourant: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -66,4 +69,29 @@ describe('AssistantesMaternellesController', () => { ); expect(res.numero_dossier).toBe('2026-000001'); }); + + it('listerCouplesGarde délègue au service (#171)', async () => { + amServiceMock.listerCouplesGarde.mockResolvedValue({ + couples: [], + couple_courant_id: null, + }); + const res = await controller.listerCouplesGarde('am-uuid'); + expect(amServiceMock.listerCouplesGarde).toHaveBeenCalledWith('am-uuid'); + expect(res.couple_courant_id).toBeNull(); + }); + + it('definirCoupleGardeCourant délègue au service (#171)', async () => { + amServiceMock.definirCoupleGardeCourant.mockResolvedValue({ + couples: [{ id: 'pl-1', courant: true }], + couple_courant_id: 'pl-1', + }); + const res = await controller.definirCoupleGardeCourant('am-uuid', { + couple_id: 'pl-1', + }); + expect(amServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith( + 'am-uuid', + 'pl-1', + ); + expect(res.couple_courant_id).toBe('pl-1'); + }); }); diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 7d5609e..8442c70 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, + Put, Body, Patch, Param, @@ -20,6 +21,8 @@ import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto'; import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto'; +import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto'; +import { DefinirCoupleGardeCourantAmDto } from './dto/definir-couple-garde-courant-am.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { User } from 'src/common/decorators/user.decorator'; @@ -83,6 +86,36 @@ export class AssistantesMaternellesController { return mapAmsForApi(ams); } + @Get('me/couples-garde') + @Roles(RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Couples de garde de l’AM connectée — ticket #171', + description: + 'Placements actifs (enfant + parents) pour peupler le bandeau couple TdB AM, ' + + 'avec indication du couple courant.', + }) + @ApiResponse({ status: 200, type: CouplesGardeAmResponseDto }) + listerCouplesGarde( + @User('id') userId: string, + ): Promise { + return this.assistantesMaternellesService.listerCouplesGarde(userId); + } + + @Put('me/couples-garde/courant') + @Roles(RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Définir le couple de garde courant (AM) — ticket #171' }) + @ApiBody({ type: DefinirCoupleGardeCourantAmDto }) + @ApiResponse({ status: 200, type: CouplesGardeAmResponseDto }) + definirCoupleGardeCourant( + @User('id') userId: string, + @Body() dto: DefinirCoupleGardeCourantAmDto, + ): Promise { + return this.assistantesMaternellesService.definirCoupleGardeCourant( + userId, + dto.couple_id, + ); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiParam({ name: 'id', description: "UUID de la nounou" }) diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts index 54a10ae..4f24c8f 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts @@ -4,13 +4,21 @@ import { AssistantesMaternellesController } from './assistantes_maternelles.cont import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AmChildren } from 'src/entities/am_children.entity'; import { Children } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/users.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]), - AuthModule + imports: [ + TypeOrmModule.forFeature([ + AssistanteMaternelle, + AmChildren, + Children, + Users, + ParentsChildren, + ]), + AuthModule, ], controllers: [AssistantesMaternellesController], providers: [AssistantesMaternellesService], @@ -19,4 +27,4 @@ import { AuthModule } from '../auth/auth.module'; TypeOrmModule, ], }) -export class AssistantesMaternellesModule { } +export class AssistantesMaternellesModule {} diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.spec.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.spec.ts index d4d7d5e..3302136 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.spec.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.spec.ts @@ -1,18 +1,150 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { Users } from 'src/entities/users.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; -describe('AssistantesMaternellesService', () => { +describe('AssistantesMaternellesService — couples de garde (#171)', () => { let service: AssistantesMaternellesService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AssistantesMaternellesService], - }).compile(); + const amRepo = { findOne: jest.fn(), update: jest.fn() }; + const usersRepo = {}; + const amChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; + const childrenRepo = {}; + const parentsChildrenRepo = { find: jest.fn() }; - service = module.get(AssistantesMaternellesService); + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssistantesMaternellesService, + { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, + { provide: getRepositoryToken(Users), useValue: usersRepo }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, + { provide: getRepositoryToken(Children), useValue: childrenRepo }, + { + provide: getRepositoryToken(ParentsChildren), + useValue: parentsChildrenRepo, + }, + ], + }).compile(); + service = module.get(AssistantesMaternellesService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('listerCouplesGarde', () => { + it('retourne vide si aucun placement', async () => { + amRepo.findOne.mockResolvedValue({ + user_id: 'am-1', + id_placement_garde_courant: null, + }); + amChildrenRepo.find.mockResolvedValue([]); + const res = await service.listerCouplesGarde('am-1'); + expect(res).toEqual({ couples: [], couple_courant_id: null }); + expect(parentsChildrenRepo.find).not.toHaveBeenCalled(); + }); + + it('mappe placements + parents et marque le courant', async () => { + amRepo.findOne.mockResolvedValue({ + user_id: 'am-1', + id_placement_garde_courant: 'pl-2', + }); + amChildrenRepo.find.mockResolvedValue([ + { + id: 'pl-1', + enfantId: 'e1', + child: { id: 'e1', first_name: 'Léa', last_name: 'M', photo_url: null }, + }, + { + id: 'pl-2', + enfantId: 'e2', + child: { id: 'e2', first_name: 'Emma', last_name: 'M', photo_url: null }, + }, + ]); + parentsChildrenRepo.find.mockResolvedValue([ + { + parentId: 'p1', + enfantId: 'e1', + parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } }, + }, + { + parentId: 'p2', + enfantId: 'e1', + parent: { user: { prenom: 'Thomas', nom: 'Martin', photo_url: null } }, + }, + { + parentId: 'p1', + enfantId: 'e2', + parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } }, + }, + ]); + + const res = await service.listerCouplesGarde('am-1'); + expect(res.couples).toHaveLength(2); + expect(res.couple_courant_id).toBe('pl-2'); + expect(res.couples[1].courant).toBe(true); + expect(res.couples[0].parents).toHaveLength(2); + expect(res.couples[0].parents[0].prenom).toBe('Claire'); + expect(res.couples[0].enfant.prenom).toBe('Léa'); + }); + + it('404 si AM inconnue', async () => { + amRepo.findOne.mockResolvedValue(null); + await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('definirCoupleGardeCourant', () => { + it('persiste si le placement appartient à l’AM', async () => { + amRepo.findOne.mockResolvedValue({ + user_id: 'am-1', + id_placement_garde_courant: null, + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e1', + }); + amRepo.update.mockResolvedValue({ affected: 1 }); + amChildrenRepo.find.mockResolvedValue([ + { + id: 'pl-1', + enfantId: 'e1', + child: { id: 'e1', first_name: 'Léa', last_name: null, photo_url: null }, + }, + ]); + parentsChildrenRepo.find.mockResolvedValue([ + { + parentId: 'p1', + enfantId: 'e1', + parent: { user: { prenom: 'Claire', nom: 'M', photo_url: null } }, + }, + ]); + + const res = await service.definirCoupleGardeCourant('am-1', 'pl-1'); + expect(amRepo.update).toHaveBeenCalledWith( + { user_id: 'am-1' }, + { id_placement_garde_courant: 'pl-1' }, + ); + expect(res.couple_courant_id).toBe('pl-1'); + expect(res.couples[0].courant).toBe(true); + }); + + it('404 si placement d’une autre AM', async () => { + amRepo.findOne.mockResolvedValue({ user_id: 'am-1' }); + amChildrenRepo.findOne.mockResolvedValue(null); + await expect( + service.definirCoupleGardeCourant('am-1', 'pl-x'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); }); diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index ced0e0e..4018040 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -5,14 +5,16 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AmChildren } from 'src/entities/am_children.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto'; import { validateNir } from 'src/common/utils/nir.util'; const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @@ -28,6 +30,8 @@ export class AssistantesMaternellesService { private readonly amChildrenRepository: Repository, @InjectRepository(Children) private readonly childrenRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, ) {} async create(dto: CreateAssistanteDto): Promise { @@ -264,4 +268,107 @@ export class AssistantesMaternellesService { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; } + + /** + * Liste les couples de garde (enfant ↔ parents) de l’AM connectée — ticket #171. + * Un couple = un placement actif dans enfants_assistantes_maternelles pour cette AM. + */ + async listerCouplesGarde(amUserId: string): Promise { + const am = await this.assistantesMaternelleRepository.findOne({ + where: { user_id: amUserId }, + }); + if (!am) { + throw new NotFoundException('Assistante maternelle introuvable'); + } + + const placements = await this.amChildrenRepository.find({ + where: { amId: amUserId, date_fin: IsNull() }, + relations: ['child'], + order: { date_debut: 'ASC' }, + }); + + if (placements.length === 0) { + return { couples: [], couple_courant_id: null }; + } + + const enfantIds = placements.map((p) => p.enfantId); + const liens = await this.parentsChildrenRepository.find({ + where: { enfantId: In(enfantIds) }, + relations: ['parent', 'parent.user'], + }); + const parentsByEnfant = new Map< + string, + { id: string; prenom: string | null; nom: string | null; photo_url: string | null }[] + >(); + for (const l of liens) { + const list = parentsByEnfant.get(l.enfantId) ?? []; + const u = l.parent?.user; + list.push({ + id: l.parentId, + prenom: u?.prenom ?? null, + nom: u?.nom ?? null, + photo_url: u?.photo_url ?? null, + }); + parentsByEnfant.set(l.enfantId, list); + } + + let idCourant = am.id_placement_garde_courant ?? null; + const idsValides = new Set(placements.map((p) => p.id)); + if (idCourant && !idsValides.has(idCourant)) { + idCourant = null; + await this.assistantesMaternelleRepository.update( + { user_id: amUserId }, + { id_placement_garde_courant: () => 'NULL' }, + ); + } + if (!idCourant && placements.length === 1) { + idCourant = placements[0].id; + } + + const couples = placements.map((p) => ({ + id: p.id, + enfant: { + id: p.child.id, + prenom: p.child.first_name ?? null, + nom: p.child.last_name ?? null, + photo_url: p.child.photo_url ?? null, + }, + parents: parentsByEnfant.get(p.enfantId) ?? [], + courant: idCourant != null && p.id === idCourant, + })); + + return { + couples, + couple_courant_id: idCourant, + }; + } + + /** + * Persiste le couple de garde actif pour l’AM — ticket #171. + */ + async definirCoupleGardeCourant( + amUserId: string, + coupleId: string, + ): Promise { + const am = await this.assistantesMaternelleRepository.findOne({ + where: { user_id: amUserId }, + }); + if (!am) { + throw new NotFoundException('Assistante maternelle introuvable'); + } + + const placement = await this.amChildrenRepository.findOne({ + where: { id: coupleId, amId: amUserId, date_fin: IsNull() }, + }); + if (!placement) { + throw new NotFoundException('Couple de garde introuvable ou inactif pour cette AM'); + } + + await this.assistantesMaternelleRepository.update( + { user_id: amUserId }, + { id_placement_garde_courant: coupleId }, + ); + + return this.listerCouplesGarde(amUserId); + } } diff --git a/backend/src/routes/assistantes_maternelles/dto/couples-garde-am.dto.ts b/backend/src/routes/assistantes_maternelles/dto/couples-garde-am.dto.ts new file mode 100644 index 0000000..3c1230c --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/couples-garde-am.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** Identité minimale enfant — ticket #171 (miroir #168) */ +export class CoupleGardeAmEnfantDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiPropertyOptional() + prenom?: string | null; + + @ApiPropertyOptional() + nom?: string | null; + + @ApiPropertyOptional() + photo_url?: string | null; +} + +/** Identité minimale parent pour bandeau AM — ticket #171 */ +export class CoupleGardeAmParentDto { + @ApiProperty({ format: 'uuid', description: 'UUID utilisateur du parent' }) + id: string; + + @ApiPropertyOptional() + prenom?: string | null; + + @ApiPropertyOptional() + nom?: string | null; + + @ApiPropertyOptional() + photo_url?: string | null; +} + +/** + * Un couple côté AM = placement actif enfant ↔ foyer parents. + * `id` = enfants_assistantes_maternelles.id (même clé que #168). + */ +export class CoupleGardeAmDto { + @ApiProperty({ + format: 'uuid', + description: 'Id du placement (enfants_assistantes_maternelles.id)', + }) + id: string; + + @ApiProperty({ type: CoupleGardeAmEnfantDto }) + enfant: CoupleGardeAmEnfantDto; + + @ApiProperty({ + type: [CoupleGardeAmParentDto], + description: 'Parents rattachés à l’enfant (0–2 typiquement)', + }) + parents: CoupleGardeAmParentDto[]; + + @ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' }) + courant: boolean; +} + +export class CouplesGardeAmResponseDto { + @ApiProperty({ type: [CoupleGardeAmDto] }) + couples: CoupleGardeAmDto[]; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: 'Id du couple courant (null si aucun)', + }) + couple_courant_id: string | null; +} diff --git a/backend/src/routes/assistantes_maternelles/dto/definir-couple-garde-courant-am.dto.ts b/backend/src/routes/assistantes_maternelles/dto/definir-couple-garde-courant-am.dto.ts new file mode 100644 index 0000000..c2ca6d4 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/definir-couple-garde-courant-am.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +/** Corps PUT couple de garde courant AM — ticket #171 */ +export class DefinirCoupleGardeCourantAmDto { + @ApiProperty({ + format: 'uuid', + description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner', + }) + @IsUUID() + @IsNotEmpty() + couple_id: string; +} diff --git a/database/BDD.sql b/database/BDD.sql index a23e4a2..b868b7e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -143,7 +143,10 @@ CREATE TABLE assistantes_maternelles ( annee_experience SMALLINT, specialite VARCHAR(100), place_disponible INT, - numero_dossier VARCHAR(20) + numero_dossier VARCHAR(20), + -- Préférence couple de garde actif (TdB quotidien AM) — ticket #171 + -- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas) + id_placement_garde_courant UUID ); CREATE INDEX idx_assistantes_maternelles_numero_dossier @@ -221,6 +224,16 @@ CREATE INDEX idx_parents_placement_garde_courant ON parents(id_placement_garde_courant) WHERE id_placement_garde_courant IS NOT NULL; +-- FK couple courant AM → placement (#171) +ALTER TABLE assistantes_maternelles + ADD CONSTRAINT fk_am_placement_garde_courant + FOREIGN KEY (id_placement_garde_courant) + REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL; + +CREATE INDEX idx_am_placement_garde_courant + ON assistantes_maternelles(id_placement_garde_courant) + WHERE id_placement_garde_courant IS NOT NULL; + -- ========================================================== -- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== diff --git a/database/migrations/2026_am_placement_garde_courant.sql b/database/migrations/2026_am_placement_garde_courant.sql new file mode 100644 index 0000000..3faaeaf --- /dev/null +++ b/database/migrations/2026_am_placement_garde_courant.sql @@ -0,0 +1,10 @@ +-- Ticket #171 — Couple de garde courant (préférence AM) +-- Idempotent : safe à rejouer. + +ALTER TABLE assistantes_maternelles + ADD COLUMN IF NOT EXISTS id_placement_garde_courant UUID + REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_am_placement_garde_courant + ON assistantes_maternelles(id_placement_garde_courant) + WHERE id_placement_garde_courant IS NOT NULL; diff --git a/database/migrations/2026_cards_system.sql b/database/migrations/2026_cards_system.sql new file mode 100644 index 0000000..4751c1b --- /dev/null +++ b/database/migrations/2026_cards_system.sql @@ -0,0 +1,123 @@ +-- Ticket #194 — Module Cartes SYSTEM (collecte absences/congés/arrêt) +-- Idempotent. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_mode_type') THEN + CREATE TYPE card_response_mode_type AS ENUM ( + 'none', 'ack', 'accept_refuse' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_instance_statut_type') THEN + CREATE TYPE card_instance_statut_type AS ENUM ( + 'ouverte', 'refusee', 'traitee' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_operation_type') THEN + CREATE TYPE card_operation_type AS ENUM ('create', 'update'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_action_type') THEN + CREATE TYPE card_response_action_type AS ENUM ( + 'accept', 'refuse', 'ack' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS card_types ( + code VARCHAR(64) PRIMARY KEY, + system BOOLEAN NOT NULL DEFAULT true, + titre VARCHAR(120) NOT NULL, + emitter_roles TEXT[] NOT NULL, + recipient_roles TEXT[] NOT NULL, + audience_resolver VARCHAR(64) NOT NULL, + response_mode card_response_mode_type NOT NULL, + retention_days INT NOT NULL DEFAULT 14, + couleur VARCHAR(32), + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS card_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type_code VARCHAR(64) NOT NULL REFERENCES card_types(code), + id_placement UUID NOT NULL + REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE, + id_absence UUID REFERENCES absences_garde(id) ON DELETE SET NULL, + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, + operation card_operation_type NOT NULL DEFAULT 'create', + statut card_instance_statut_type NOT NULL DEFAULT 'ouverte', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + purge_at TIMESTAMPTZ NOT NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_card_instances_placement + ON card_instances (id_placement, statut, cree_le DESC); +CREATE INDEX IF NOT EXISTS idx_card_instances_purge + ON card_instances (purge_at); +CREATE INDEX IF NOT EXISTS idx_card_instances_absence + ON card_instances (id_absence) + WHERE id_absence IS NOT NULL; + +CREATE TABLE IF NOT EXISTS card_audience_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE, + id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + role_snapshot VARCHAR(64) NOT NULL, + is_creator BOOLEAN NOT NULL DEFAULT false, + UNIQUE (id_card, id_utilisateur) +); + +CREATE INDEX IF NOT EXISTS idx_card_audience_user + ON card_audience_members (id_utilisateur, id_card); + +CREATE TABLE IF NOT EXISTS card_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE, + id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + action card_response_action_type NOT NULL, + comment TEXT, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_card_responses_card + ON card_responses (id_card, cree_le DESC); + +-- Seeds SYSTEM +INSERT INTO card_types ( + code, system, titre, emitter_roles, recipient_roles, + audience_resolver, response_mode, retention_days, couleur +) VALUES + ( + 'absence_enfant', true, 'Absence enfant', + ARRAY['parent'], ARRAY['assistante_maternelle'], + 'couple_am', 'none', 7, 'peach' + ), + ( + 'absence_enfant_modif', true, 'Absence enfant modifiée', + ARRAY['parent'], ARRAY['assistante_maternelle'], + 'couple_am', 'ack', 7, 'peach' + ), + ( + 'conge_am', true, 'Congé AM', + ARRAY['assistante_maternelle'], ARRAY['parent'], + 'couple_parents', 'accept_refuse', 14, 'lavender' + ), + ( + 'arret_maladie_am', true, 'Arrêt maladie AM', + ARRAY['assistante_maternelle'], ARRAY['parent'], + 'couple_parents', 'ack', 14, 'pink' + ) +ON CONFLICT (code) DO UPDATE SET + titre = EXCLUDED.titre, + emitter_roles = EXCLUDED.emitter_roles, + recipient_roles = EXCLUDED.recipient_roles, + audience_resolver = EXCLUDED.audience_resolver, + response_mode = EXCLUDED.response_mode, + retention_days = EXCLUDED.retention_days, + couleur = EXCLUDED.couleur, + modifie_le = now(); + +COMMENT ON TABLE card_types IS 'Catalogue types de cartes (SYSTEM seeds #194 ; OPTIONNEL plus tard).'; +COMMENT ON TABLE card_instances IS 'Bulles / file d’attention — collecte, pas vérité métier absences.'; diff --git a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md index d519a14..ee69363 100644 --- a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md +++ b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md @@ -107,7 +107,7 @@ Liste des enfants / foyers pour l’AM + contexte courant (symétrique A3). | BDD | Table `absences_garde` + drop `evenements` | | API | CRUD + **GET liste** (`placementId` \| tous les placements du user) | | Cartes SYSTEM | Module `cards/` types S1–S3 (sans sondages V1) | -| Realtime | WS/SSE bulles | +| Realtime | WS/SSE bulles → **SSE** `GET /cards/stream` (#195) | | Purge TTL | Job `expire_at` / `purge_at` | ### Front (après API — hors chantier back immédiat) diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 1853bfe..c223797 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:icon="@mipmap/launcher_icon"> json) { + return AbsenceGarde( + id: json['id'] ?? '', + idPlacement: json['id_placement'] ?? '', + type: json['type'] ?? '', + dateDebut: json['date_debut'] ?? '', + dateFin: json['date_fin'] ?? '', + statut: json['statut'] ?? '', + expireAt: json['expire_at'] ?? '', + creePar: json['cree_par'], + motif: json['motif'], + idEnfant: json['id_enfant'], + prenomEnfant: json['prenom_enfant'], + idAm: json['id_am'], + prenomAm: json['prenom_am'], + nomAm: json['nom_am'], + creeLe: json['cree_le'] ?? '', + modifieLe: json['modifie_le'] ?? '', + ); + } +} diff --git a/frontend/lib/screens/am/am_dashboard_screen.dart b/frontend/lib/screens/am/am_dashboard_screen.dart index fea0afe..5a2201e 100644 --- a/frontend/lib/screens/am/am_dashboard_screen.dart +++ b/frontend/lib/screens/am/am_dashboard_screen.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/couple_garde.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; -import 'package:p_tits_pas/widgets/app_footer.dart'; -import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; +import 'package:p_tits_pas/services/couple_garde_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; +import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'; -/// Dashboard assistante maternelle – page blanche avec bandeau générique. -/// Contenu détaillé à venir. +/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). +/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170). +/// Métier cartes / blog / messagerie : tickets C/D/E. class AmDashboardScreen extends StatefulWidget { const AmDashboardScreen({super.key}); @@ -14,13 +19,19 @@ class AmDashboardScreen extends StatefulWidget { } class _AmDashboardScreenState extends State { - int selectedTabIndex = 0; + QuotidienNavSection _section = QuotidienNavSection.liaison; AppUser? _user; + List _couples = const []; + String? _selectedCoupleId; + bool _couplesLoading = true; + String? _couplesError; + @override void initState() { super.initState(); _loadUser(); + _loadCouples(); } Future _loadUser() async { @@ -28,51 +39,152 @@ class _AmDashboardScreenState extends State { if (mounted) setState(() => _user = user); } + Future _loadCouples() async { + setState(() { + _couplesLoading = true; + _couplesError = null; + }); + try { + // TODO: Pour l'instant on utilise le service générique de test (CoupleGardeService.getCouplesGarde). + // Dans le futur, l'API pour l'AM (enfants_accueillis) fournira la vraie liste. + final res = await CoupleGardeService.getCouplesGarde(); + if (!mounted) return; + setState(() { + _couples = res.couples; + _selectedCoupleId = res.coupleCourant?.id; + _couplesLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _couplesError = e.toString().replaceFirst('Exception: ', ''); + _couplesLoading = false; + }); + } + } + + Future _selectCouple(CoupleGarde couple) async { + if (couple.id == _selectedCoupleId) return; + setState(() => _selectedCoupleId = couple.id); + try { + final res = await CoupleGardeService.definirCoupleCourant(couple.id); + if (!mounted) return; + setState(() { + _couples = res.couples; + _selectedCoupleId = res.coupleCourant?.id ?? couple.id; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Impossible de changer d'enfant : " + '${e.toString().replaceFirst('Exception: ', '')}', + ), + ), + ); + } + } + + String get _displayName { + final n = _user?.fullName.trim() ?? ''; + if (n.isNotEmpty) return n; + final email = _user?.email.trim() ?? ''; + if (email.isNotEmpty) return email.split('@').first; + return 'Assistante maternelle'; + } + + void _soon(String label) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$label — à venir')), + ); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60.0), - child: DashboardBandeau( - tabItems: const [ - DashboardTabItem(label: 'Mon tableau de bord'), - DashboardTabItem(label: 'Paramètres'), - ], - selectedTabIndex: selectedTabIndex, - onTabSelected: (index) => setState(() => selectedTabIndex = index), - userDisplayName: _user?.fullName.isNotEmpty == true - ? _user!.fullName - : 'Assistante maternelle', - userEmail: _user?.email, - userRole: _user?.role, - onProfileTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Modification du profil – à venir')), - ); - }, - onSettingsTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Paramètres – à venir')), - ); - }, - onLogout: () {}, - showLogoutConfirmation: true, - ), + return QuotidienShell( + selectedSection: _section, + onSectionSelected: (s) => setState(() => _section = s), + userDisplayName: _displayName, + userEmail: _user?.email, + onProfileTap: () => _soon('Profil'), + onSettingsTap: () => _soon('Paramètres'), + // L'AM n'a pas de bouton "Recherche AM" dans le bandeau + onSearchAmTap: null, + leftColumn: _LeftColumn( + couples: _couples, + selectedCoupleId: _selectedCoupleId, + loading: _couplesLoading, + error: _couplesError, + onRetry: _loadCouples, + onCoupleSelected: _selectCouple, ), - body: Column( + centerColumn: const QuotidienColumnPlaceholder( + title: 'Blog', + subtitle: + 'Fil du quotidien (affichage par défaut)\n(à brancher — ticket #179).', + icon: Icons.auto_stories_outlined, + ), + rightColumn: const QuotidienColumnPlaceholder( + title: 'Messagerie', + subtitle: + 'Mess. Parents · Mess. RPE\n(à brancher — ticket #185).', + icon: Icons.chat_bubble_outline, + ), + agendaBody: AgendaAbsencesStub(placementId: _selectedCoupleId), + contratBody: const QuotidienStubPage( + title: 'Contrat', + message: 'Contrat — contenu à venir (stub #187).', + ), + ); + } +} + +class _LeftColumn extends StatelessWidget { + final List couples; + final String? selectedCoupleId; + final bool loading; + final String? error; + final VoidCallback onRetry; + final ValueChanged onCoupleSelected; + + const _LeftColumn({ + required this.couples, + required this.selectedCoupleId, + required this.loading, + required this.error, + required this.onRetry, + required this.onCoupleSelected, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Center( - child: Text( - 'Dashboard AM – à venir', - style: Theme.of(context).textTheme.titleLarge, - ), + CoupleSelectorBandeau( + mode: CoupleBandeauMode.assistanteMaternelle, + couples: couples, + selectedCoupleId: selectedCoupleId, + loading: loading, + errorMessage: error, + onRetry: onRetry, + onCoupleSelected: onCoupleSelected, + ), + const SizedBox(height: 14), + const Expanded( + child: QuotidienColumnPlaceholder( + title: 'Cartes', + subtitle: + 'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).', + icon: Icons.style_outlined, ), ), - const AppFooter(), ], ), ); } } + diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index 6a35615..e2d2e82 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -6,6 +6,7 @@ import 'package:p_tits_pas/services/couple_garde_service.dart'; import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; +import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'; /// Tableau de bord parent — coquille 3 colonnes quotidien (#166). /// Colonne gauche : sélecteur de couple enfant–nounou (#167). @@ -128,10 +129,7 @@ class _ParentDashboardScreenState extends State { 'Mess. AM · Mess. RPE\n(à brancher — ticket #184).', icon: Icons.chat_bubble_outline, ), - agendaBody: const QuotidienStubPage( - title: 'Agenda', - message: 'Agenda — contenu à venir (stub #187).', - ), + agendaBody: AgendaAbsencesStub(placementId: _selectedCoupleId), contratBody: const QuotidienStubPage( title: 'Contrat', message: 'Contrat — contenu à venir (stub #187).', diff --git a/frontend/lib/screens/home/parent_screen/agenda_absences_stub.dart b/frontend/lib/screens/home/parent_screen/agenda_absences_stub.dart new file mode 100644 index 0000000..ec3f141 --- /dev/null +++ b/frontend/lib/screens/home/parent_screen/agenda_absences_stub.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/absence_garde.dart'; +import 'package:p_tits_pas/services/api/absences_garde_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; + +class AgendaAbsencesStub extends StatefulWidget { + final String? placementId; + + const AgendaAbsencesStub({super.key, this.placementId}); + + @override + State createState() => _AgendaAbsencesStubState(); +} + +class _AgendaAbsencesStubState extends State { + List _absences = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadAbsences(); + } + + @override + void didUpdateWidget(covariant AgendaAbsencesStub oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.placementId != widget.placementId) { + _loadAbsences(); + } + } + + Future _loadAbsences() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final absences = await AbsencesGardeService.getAbsences( + placementId: widget.placementId, + ); + if (!mounted) return; + setState(() { + _absences = absences; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + color: QuotidienTheme.ivory, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.calendar_month_outlined, + size: 64, + color: QuotidienTheme.peach, + ), + const SizedBox(height: 24), + Text( + "Agenda (Stub) - Lignes d'absence", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: QuotidienTheme.ink, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + 'ID Placement courant: ${widget.placementId ?? 'Tous'}', + textAlign: TextAlign.center, + style: const TextStyle(color: QuotidienTheme.muted), + ), + const SizedBox(height: 24), + Expanded( + child: _buildList(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildList() { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadAbsences, + child: const Text('Réessayer'), + ), + ], + ), + ); + } + if (_absences.isEmpty) { + return const Center( + child: Text('Aucune absence ou congé trouvé.', + style: TextStyle(color: QuotidienTheme.muted)), + ); + } + return ListView.separated( + itemCount: _absences.length, + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (context, index) { + final abs = _absences[index]; + return ListTile( + leading: _getIcon(abs.type), + title: Text('${abs.type} (${abs.statut})'), + subtitle: Text( + 'Du ${abs.dateDebut} au ${abs.dateFin}\n' + 'Enfant: ${abs.prenomEnfant ?? 'N/A'}, AM: ${abs.prenomAm ?? 'N/A'}', + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () => _confirmDelete(abs), + ), + ); + }, + ); + } + + Icon _getIcon(String type) { + switch (type) { + case 'absence_enfant': + return const Icon(Icons.child_care, color: QuotidienTheme.coral); + case 'conge_am': + return const Icon(Icons.beach_access, color: QuotidienTheme.turquoise); + case 'arret_maladie_am': + return const Icon(Icons.medical_services, color: QuotidienTheme.coral); + default: + return const Icon(Icons.event); + } + } + + Future _confirmDelete(AbsenceGarde abs) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer ?'), + content: Text("Supprimer l'absence ${abs.type} du ${abs.dateDebut} ?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true) { + try { + await AbsencesGardeService.supprimerAbsence(abs.id); + _loadAbsences(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } +} diff --git a/frontend/lib/services/api/absences_garde_service.dart b/frontend/lib/services/api/absences_garde_service.dart new file mode 100644 index 0000000..0b5181d --- /dev/null +++ b/frontend/lib/services/api/absences_garde_service.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/absence_garde.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +class AbsencesGardeService { + static Future> getAbsences({String? placementId}) async { + final token = await TokenService.getToken(); + final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers; + + final uri = Uri.parse(ApiConfig.baseUrl + + '/absences-garde' + + (placementId != null ? '?placementId=$placementId' : '')); + + final res = await http.get(uri, headers: headers); + if (res.statusCode == 200) { + final json = jsonDecode(res.body); + final List items = json['items'] ?? []; + return items.map((e) => AbsenceGarde.fromJson(e)).toList(); + } else { + throw Exception('Erreur de chargement des absences : ${res.statusCode}'); + } + } + + static Future creerAbsence({ + required String idPlacement, + required String type, + required String dateDebut, + required String dateFin, + String? motif, + }) async { + final token = await TokenService.getToken(); + final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers; + + final uri = Uri.parse('${ApiConfig.baseUrl}/absences-garde'); + final res = await http.post( + uri, + headers: headers, + body: jsonEncode({ + 'id_placement': idPlacement, + 'type': type, + 'date_debut': dateDebut, + 'date_fin': dateFin, + if (motif != null) 'motif': motif, + }), + ); + if (res.statusCode == 201) { + return AbsenceGarde.fromJson(jsonDecode(res.body)); + } else { + throw Exception("Erreur de création d'absence : ${res.statusCode}"); + } + } + + static Future modifierAbsence( + String id, { + String? dateDebut, + String? dateFin, + String? statut, + String? motif, + }) async { + final token = await TokenService.getToken(); + final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers; + + final uri = Uri.parse('${ApiConfig.baseUrl}/absences-garde/$id'); + final Map body = {}; + if (dateDebut != null) body['date_debut'] = dateDebut; + if (dateFin != null) body['date_fin'] = dateFin; + if (statut != null) body['statut'] = statut; + if (motif != null) body['motif'] = motif; + + final res = await http.patch( + uri, + headers: headers, + body: jsonEncode(body), + ); + if (res.statusCode == 200) { + return AbsenceGarde.fromJson(jsonDecode(res.body)); + } else { + throw Exception("Erreur de modification d'absence : ${res.statusCode}"); + } + } + + static Future supprimerAbsence(String id) async { + final token = await TokenService.getToken(); + final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers; + + final uri = Uri.parse('${ApiConfig.baseUrl}/absences-garde/$id'); + final res = await http.delete(uri, headers: headers); + if (res.statusCode != 204) { + throw Exception("Erreur de suppression d'absence : ${res.statusCode}"); + } + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index d7ec64f..f063386 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -96,7 +96,8 @@ class ApiConfig { }; static Map authHeaders(String token) => { - ...headers, + 'Content-Type': 'application/json', + 'Accept': 'application/json', 'Authorization': 'Bearer $token', }; } diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 1871e07..ddee7fb 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -383,6 +383,13 @@ class AuthService { } /// Récupère l'utilisateur connecté depuis le cache + static const String tokenKey = 'auth_token'; + + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(tokenKey); + } + static Future getCurrentUser() async { final prefs = await SharedPreferences.getInstance(); final userJson = prefs.getString(_currentUserKey); diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart index b34aa00..963d8b3 100644 --- a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -135,6 +135,16 @@ class _CoupleCard extends StatelessWidget { @override Widget build(BuildContext context) { + // Parent mode: enfant | AM + // AM mode: enfant | parent(s) + final isParentMode = mode == CoupleBandeauMode.parent; + final fallbackRoleIcon = isParentMode ? Icons.volunteer_activism : Icons.person_outline; + final fallbackRoleName = isParentMode ? 'Nounou' : 'Parent'; + + // TODO: In AM mode, we should ideally display "parent1+parent2 empilés" or centered. + // For now, CoupleGarde only gives us `am` (which in AM mode might just represent one parent, or the system needs to feed both parents here). + // Assuming backend will populate `am` field with the parent data when called by AM. + return Container( height: 90, decoration: BoxDecoration( @@ -175,14 +185,8 @@ class _CoupleCard extends StatelessWidget { photoUrl: couple.am.photoUrl, name: (couple.am.prenom != null && couple.am.prenom!.isNotEmpty) ? couple.am.prenom! - : couple.am.displayName( - fallback: mode == CoupleBandeauMode.parent - ? 'Nounou' - : 'Parent', - ), - fallbackIcon: mode == CoupleBandeauMode.parent - ? Icons.volunteer_activism - : Icons.person_outline, + : couple.am.displayName(fallback: fallbackRoleName), + fallbackIcon: fallbackRoleIcon, ), ), ), diff --git a/frontend/lib/widgets/quotidien/quotidien_bandeau.dart b/frontend/lib/widgets/quotidien/quotidien_bandeau.dart index 16e0399..2bb5e98 100644 --- a/frontend/lib/widgets/quotidien/quotidien_bandeau.dart +++ b/frontend/lib/widgets/quotidien/quotidien_bandeau.dart @@ -183,15 +183,16 @@ class _UserMenu extends StatelessWidget { title: Text('Profil'), ), ), - const PopupMenuItem( - value: 'search_am', - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.search, size: 20), - title: Text('Recherche AM'), + if (onSearchAmTap != null) + const PopupMenuItem( + value: 'search_am', + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.search, size: 20), + title: Text('Recherche AM'), + ), ), - ), const PopupMenuItem( value: 'settings', child: ListTile( diff --git a/frontend/lib/widgets/quotidien/quotidien_theme.dart b/frontend/lib/widgets/quotidien/quotidien_theme.dart index 188b5a2..dd5c3bf 100644 --- a/frontend/lib/widgets/quotidien/quotidien_theme.dart +++ b/frontend/lib/widgets/quotidien/quotidien_theme.dart @@ -6,6 +6,7 @@ abstract final class QuotidienTheme { static const Color ink = Color(0xFF2F2F2F); static const Color ivory = Color(0xFFFFFEF9); static const Color turquoise = Color(0xFF8AD0C8); + static const Color peach = Color(0xFFFFCCB6); static const Color lavender = Color(0xFFC6A3D8); static const Color coral = Color(0xFFF4A28C); static const Color softGreenPill = Color(0xFFB8D9A8); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 3672396..077aaa8 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -25,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -150,6 +182,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -213,6 +253,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" image_picker: dependency: "direct main" description: @@ -293,6 +341,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -461,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: "direct main" description: @@ -722,6 +786,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.19.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index b3f4ca8..ccad368 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -30,6 +30,21 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + flutter_launcher_icons: ^0.13.1 + +flutter_launcher_icons: + android: "launcher_icon" + ios: false + image_path: "assets/images/icon.png" + web: + generate: true + image_path: "assets/images/icon.png" + background_color: "#ffffff" + theme_color: "#ffffff" + windows: + generate: true + image_path: "assets/images/icon.png" + icon_size: 256 flutter: uses-material-design: true diff --git a/frontend/web/favicon.png b/frontend/web/favicon.png index 8aaa46a..f54d87b 100644 Binary files a/frontend/web/favicon.png and b/frontend/web/favicon.png differ diff --git a/frontend/web/icons/Icon-192.png b/frontend/web/icons/Icon-192.png index b749bfe..b91095e 100644 Binary files a/frontend/web/icons/Icon-192.png and b/frontend/web/icons/Icon-192.png differ diff --git a/frontend/web/icons/Icon-512.png b/frontend/web/icons/Icon-512.png index 88cfd48..28f013b 100644 Binary files a/frontend/web/icons/Icon-512.png and b/frontend/web/icons/Icon-512.png differ diff --git a/frontend/web/icons/Icon-maskable-192.png b/frontend/web/icons/Icon-maskable-192.png index eb9b4d7..b91095e 100644 Binary files a/frontend/web/icons/Icon-maskable-192.png and b/frontend/web/icons/Icon-maskable-192.png differ diff --git a/frontend/web/icons/Icon-maskable-512.png b/frontend/web/icons/Icon-maskable-512.png index d69c566..28f013b 100644 Binary files a/frontend/web/icons/Icon-maskable-512.png and b/frontend/web/icons/Icon-maskable-512.png differ diff --git a/frontend/web/manifest.json b/frontend/web/manifest.json index 9a2dd4e..d355635 100644 --- a/frontend/web/manifest.json +++ b/frontend/web/manifest.json @@ -3,19 +3,19 @@ "short_name": "P'titsPas", "start_url": ".", "display": "standalone", - "background_color": "#FFFEF9", - "theme_color": "#8AD0C8", + "background_color": "#ffffff", + "theme_color": "#ffffff", "description": "P'titsPas - Grandir pas à pas, sereinement", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ { - "src": "assets/images/icon.png", + "src": "icons/Icon-192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "assets/images/icon.png", + "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" }, @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/frontend/windows/runner/resources/app_icon.ico b/frontend/windows/runner/resources/app_icon.ico index c04e20c..574bc82 100644 Binary files a/frontend/windows/runner/resources/app_icon.ico and b/frontend/windows/runner/resources/app_icon.ico differ diff --git a/issues.json b/issues.json new file mode 100644 index 0000000..01cee8c --- /dev/null +++ b/issues.json @@ -0,0 +1 @@ +[{"id":304,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/196","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/196","number":196,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Purge TTL absences en attente/refusées + cartes expirées","body":"## Contexte\n\nEpic [#165](https://git.ptits-pas.fr/jmartin/petitspas/issues/165). \nÉviter de polluer la base : `expire_at` / `purge_at` déjà prévus au modèle.\n\n## Règles indicatives (configurables)\n\n- `en_attente` ≈ **15 j** → DELETE absence + cartes liées\n- `refuse` ≈ **7 j** → DELETE si AM n’a pas repris\n- Cartes « traitées » → DELETE après `retention_days` (absence `accepte` **conservée**)\n\n## À faire\n\n- Job scheduler (cron) de purge\n- (Optionnel / ticket lié) relances push parents avant expiration\n\n## Dépend de\n\n- BDD + API absences + module Cartes (colonnes présentes)\n","ref":"","assets":[],"labels":[{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-24T09:02:35Z","updated_at":"2026-09-24T09:02:53Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":303,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/195","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/195","number":195,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Realtime module Cartes (WS/SSE)","body":"## Contexte\n\nEpic [#165](https://git.ptits-pas.fr/jmartin/petitspas/issues/165). \nDiffusion quasi-instantanée des bulles (création congé → parents ; refus → AM ; agrégats plus tard).\n\n## À faire\n\n- Gateway dans le module Cartes (WS ou SSE)\n- Auth JWT ; rooms user / card\n- Events : `card.created`, `card.updated`, `response.added`, …\n\n## Dépend de\n\n- Module Cartes SYSTEM\n\n## Hors scope\n\n- Push mobile / relances (autre ticket)\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-24T09:02:35Z","updated_at":"2026-09-24T09:02:53Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":300,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/192","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/192","number":192,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front][Évolution] Desktop — superposer cartes et blog (2 panneaux, priorité cartes)","body":"## Contexte\n\nSur desktop, la colonne **cartes / bulles** risque d être souvent peu remplie (usage ponctuel : congés, absences, validations). Blog et messagerie sont plus continus.\n\n## Proposition (évolution — hors 0.2.0)\n\nLayout desktop en **2 panneaux** :\n- un côté : **messagerie**\n- l autre : **cartes OU blog** (superposés)\n - s il reste des bulles actionnables / non purgées → **priorité cartes**\n - sinon → **blog** seul\n\n**Mobile** : conserver les **3 slides** (cartes | blog | messagerie) comme maquetté actuellement.\n\n## Hors scope immédiat\n\nNe pas toucher à la coquille 3 colonnes livrée en 0.2.0 / Epic A. Ce ticket est un **backlog futur** ; s il devient obsolète → fermer.\n\n## Labels\n\nenhancement, frontend, ux — **pas** de milestone 0.2.0, **pas** de label v0.2.0.\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"}],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-24T08:59:29Z","updated_at":"2026-09-24T09:02:53Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":297,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/189","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/189","number":189,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Jeu de données peuplé quotidien (seeds)","body":"## Contexte (G1)\nPour démontrer la sync multi-écrans sans saisie manuelle lourde.\n\n## À faire\nScripts / seeds : un foyer avec **2 parents**, une AM, un ou plusieurs enfants, rattachements ; quelques **cartes** (absence, congé à valider, sortie) ; **posts blog** AM et RPE ; fils **Mess. AM** et éventuellement RPE. Documenter comptes / mots de passe de test (ou lier `docs/test-data/`).\n\n## Done when\nAvec les seeds, on peut enchaîner les critères démo de l’epic #165 / mini-spec §7 sur 2 supports.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:54Z","updated_at":"2026-09-23T15:11:32Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":296,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/188","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/188","number":188,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Adaptation mobile — swipe 3 panneaux","body":"## Contexte (F2)\nAnticipation mobile : les 3 colonnes PC deviennent 3 panneaux swipables.\n\n## À faire\nSur téléphone : swipe entre **Blog** (défaut proposé) ↔ **Cartes** (+ couple) ↔ **Messagerie**. La navigation TdB / Agenda / Contrat reste hors swipe (boutons / menu). Si Contrat est trop lourd sur mobile, limitation V1 acceptable (« mieux sur ordinateur ») — documenter le choix UI.\n\n## Done when\nSur viewport étroit, on bascule les 3 panneaux au doigt / geste sans perdre le bandeau métier.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:53Z","updated_at":"2026-09-23T15:11:32Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":295,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/187","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/187","number":187,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Navigation Agenda / Contrat (stubs)","body":"## Contexte (F1)\nLe bandeau expose Agenda et Contrat, mais le **contenu riche** est hors jalon 0.2.0 (Contrat = module valué plus tard ; Agenda calendrier = plus tard). Il faut quand même des cibles de navigation.\n\n## À faire\nRoutes / écrans **placeholder** (ou squelette) Agenda et Contrat en pleine page, accessibles depuis le bandeau, message clair « à venir » / squelette. Ne pas implémenter Pajemploi, CP, grille calendrier riche ici.\n\n## Done when\nClic Agenda et Contrat depuis le TdB ouvre une page dédiée sans casser la nav, sans faux air de fonctionnalité terminée.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:53Z","updated_at":"2026-09-23T21:40:40Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":294,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/186","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/186","number":186,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Mess. RPE côté gestionnaire (entrée minimale)","body":"## Contexte (E6)\nEntrée gestionnaire pour la médiation RPE.\n\n## À faire\nUI minimale staff : liste des fils RPE, réponse, **ajout de participants**. Réutiliser le widget messagerie autant que possible.\n\n## Done when\nLe gestionnaire répond et invite parent/AM dans un fil de médiation.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":55,"name":"gestionnaire","exclusive":false,"is_archived":false,"color":"ff9800","description":"Gestionnaire","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/55"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:52Z","updated_at":"2026-09-23T15:11:31Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":293,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/185","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/185","number":185,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Colonne messagerie AM (onglets équivalents)","body":"## Contexte (E5)\nColonne messagerie AM.\n\n## À faire\n**Réutiliser** le widget #184. Mess. AM = foyer/couple courant ; Mess. RPE = canal relais. Pas de second chat recopié.\n\n## Done when\nL’AM échange avec le foyer courant et accède au canal RPE dans la même UI.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:52Z","updated_at":"2026-09-23T15:11:31Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":292,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/184","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/184","number":184,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Colonne messagerie parent (Mess. AM | Mess. RPE)","body":"## Contexte (E4)\nColonne droite TdB parent.\n\n## À faire\nWidget **messagerie** réutilisable : onglets **Mess. AM** (sélectionné par défaut) | **Mess. RPE** ; zone de conversation ; champ saisie + envoi ; pièces jointes images (usage conversationnel, pas un substitut blog). Branché couple actif + APIs #182/#183.\n\n## Widgets partagés\nMême brique pour #185 et #186.\n\n## Done when\nLe parent chat en Mess. AM et peut basculer vers Mess. RPE sans quitter la colonne.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:51Z","updated_at":"2026-09-23T21:40:40Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":291,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/183","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/183","number":183,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Mess. RPE — privée + ajout de participants","body":"## Contexte (E3)\nMessagerie RPE = médiation / conflit, **pas** le canal du quotidien (l’UI par défaut reste Mess. AM).\n\n## À faire\nFil **privé 1↔1** par défaut (ex. parent↔gestionnaire ou AM↔gestionnaire). L’initiateur peut **ajouter** des participants (2ᵉ parent, AM, parent…) pour résoudre un problème. Droits et invitations clairs côté API.\n\n## Done when\nOn ouvre un fil RPE privé, on ajoute un tiers, tous les participants voient le fil élargi.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":55,"name":"gestionnaire","exclusive":false,"is_archived":false,"color":"ff9800","description":"Gestionnaire","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/55"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:51Z","updated_at":"2026-09-23T15:11:30Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":290,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/182","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/182","number":182,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Mess. AM — conversation foyer ↔ AM","body":"## Contexte (E2)\nCanal quotidien foyer ↔ assistante maternelle.\n\n## À faire\nUne conversation **par couple/foyer** liée au contexte de garde. Les **deux parents** déclarés voient **exactement le même** fil (pas de masquage V1). Brancher sur le socle #181.\n\n## Done when\nParent1, parent2 et AM partagent la même conversation ; un message d’un côté apparaît de l’autre.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:50Z","updated_at":"2026-09-23T15:11:30Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":289,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/181","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/181","number":181,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Socle messagerie (lib / protocole) + API","body":"## Contexte (E1)\nSocle technique messagerie avant les cas d’usage AM/RPE. Style attendu type WhatsApp (texte, emoji, images) via **brique existante** si pertinent — documenter le choix.\n\n## À faire\nChoisir lib/protocole (noter ADR court dans le ticket ou `docs/24_…`). Implémenter conversations, participants, messages, PJ images. Prévoir **temps réel** (WebSocket ou équivalent) pour l’effet multi-écrans ; polling documenté OK en fallback V1.\n\n## Hors scope\nMasquage de messages / fils « privés » entre un parent et l’AM (plus tard).\n\n## Done when\nDécision technique écrite + API de base permettant d’envoyer/recevoir un message avec image entre deux comptes de test.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:50Z","updated_at":"2026-09-23T15:11:30Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":288,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/180","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/180","number":180,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Publication blog gestionnaire (entrée staff)","body":"## Contexte (D4)\nLe gestionnaire RPE publie des actus / annonces institutionnelles.\n\n## À faire\nPoint d’entrée **minimal** dans le dashboard staff (gestionnaire) pour composer une annonce (réutiliser le composeur blog). Publication visible sur les TdB parent/AM concernés. Pas besoin d’un CMS complet V1.\n\n## Done when\nUn gestionnaire publie ; le post apparaît avec badge/auteur RPE distinct côté parent/AM.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":55,"name":"gestionnaire","exclusive":false,"is_archived":false,"color":"ff9800","description":"Gestionnaire","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/55"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:49Z","updated_at":"2026-09-23T15:11:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":287,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/179","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/179","number":179,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Colonne Blog AM — lecture + écrire un post","body":"## Contexte (D3)\nCôté AM : lire le fil et publier l’activité du jour (typiquement le soir, ~5 min, pas d’obligation quotidienne).\n\n## À faire\n**Réutiliser** le widget fil (#178). Ajouter le **composeur** partagé : texte + photos + **sélection des enfants concernés** (cocher/décocher) puis publier via #177.\n\n## Done when\nL’AM publie un post avec photo ; le parent le voit en colonne blog.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:49Z","updated_at":"2026-09-23T15:11:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":286,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/178","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/178","number":178,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Colonne Blog parent (affichage défaut)","body":"## Contexte (D2)\nColonne milieu du TdB parent : affichage **par défaut** à l’arrivée.\n\n## À faire\nWidget **fil blog** : cartes post avec auteur **visuellement distinct** AM vs RPE, titre/texte, miniatures photos. Pas de bouton « Écrire un post » pour le parent en V1 (reporté). Branché sur le couple actif. Look pastel maquette v4.\n\n## Widgets partagés\nBrique fil réutilisée par AM (#179) et staff (#180 pour la lecture/cible).\n\n## Done when\nLe parent ouvre le TdB sur le blog et lit les posts AM/RPE du contexte.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:48Z","updated_at":"2026-09-23T21:40:40Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":285,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/177","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/177","number":177,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] Modèle + API blog (posts + médias)","body":"## Contexte (D1)\nLe blog est la **colonne centrale indispensable** (plus un module optionnel CDC §9). Canal distinct des cartes et de la messagerie.\n\n## À faire\nModèle posts + médias (photos) : auteurs **AM** et **gestionnaire (RPE)** en V1 (pas parent). Texte + pièces jointes images ; ciblage enfants pour un post AM ; audience / ciblage pour un post RPE. Fil consultable par les parents (et AM selon règles). Sync temps réel ou **polling** acceptable en V1.\n\n## Done when\nAPI permet créer/lister un fil filtré par contexte de garde, avec médias, sans coller ça dans la messagerie.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:48Z","updated_at":"2026-09-23T15:11:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":284,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/176","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/176","number":176,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Modale « Ajouter une bulle » — types AM (congé / arrêt)","body":"## Contexte (ex-C5, recentré)\n\nMême **modale générique** que le ticket parent : l’AM sélectionne **Congé** ou **Arrêt maladie** (pas de sortie/sondage dans ce ticket).\n\n## Dépend de\n\n- Modale générique (socle)\n- Module Cartes SYSTEM + API absences\n\n## À faire\n\n- Types dans la combo AM :\n - **Congé AM** : date début/fin → période `en_attente` + bulles parents + miroir AM ; `expire_at`\n - **Arrêt maladie AM** : date début/fin → flux **ack** parent (« bien reçu ») ; **aucun** upload médical\n- Modification depuis bulle « en attente » (avant accept parent) = réouvrir la modale / mêmes champs\n- Modification d’un congé **déjà accepté** = `operation=update` (même id absence, re-validation parents) — si le backend est prêt ; sinon stub + ticket follow-up\n- Sortie / autorisation / sondage = **hors ce ticket**\n\n## Done when\n\n- L’AM crée congé et arrêt depuis « Ajouter une bulle »\n- Les parents concernés voient les bulles à traiter\n- Pas de doc médical stocké\n\n### Réf.\n\n- Mini-spec § règles métier · workflows S2/S3\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:48Z","updated_at":"2026-09-24T09:04:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":283,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/175","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/175","number":175,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Modale générique « Ajouter une bulle » + absence parent","body":"## Contexte (ex-C4, élargi)\n\nEntrée unique de création : bouton **Ajouter une bulle** → **modale générique adaptative** (widget unique tous profils).\n\nV1 : au minimum le parcours **parent = déclarer une absence enfant** ; la combo « type de demande » est filtrée par rôle (prêt pour AM/gest plus tard).\n\n## Dépend de\n\n- API absences + (idéalement) module Cartes SYSTEM\n- Feed parent pour voir le résultat\n\n## À faire\n\n- Widget `CreateCardModal` (ou équivalent) :\n - combo **Type** en haut (options selon `emitter_roles` / profil)\n - corps **piloté par le type** (champs absences = date début/fin + motif optionnel)\n- Soumission parent absence :\n - écriture back absences (**acceptée** tout de suite, pas de veto AM)\n - diffusion bulle côté AM (+ miroir parent)\n- Libellé : **Déclarer une absence** (pas « soumettre congé »)\n- Couple / placement courant (#168)\n\n## Hors scope V1 de ce ticket\n\n- Champs sondage / date+heure événement (prévoir l’extensibilité du widget seulement)\n- Formulaire AM congé/arrêt → ticket formulaires AM (même modale, autres types)\n\n## Done when\n\n- Parent ouvre la modale, choisit absence, valide → période en base + bulles visibles\n- Le widget est clairement réutilisable pour d’autres types / rôles\n\n### Réf.\n\n- Mini-spec · plan mécanique cartes / absences\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:47Z","updated_at":"2026-09-24T09:04:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":282,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/174","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/174","number":174,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Feed bulles / cartes (AM) — réutilise widget parent","body":"## Contexte (ex-C3, recentré)\n\nMiroir AM du feed bulles — **même widget** que le feed parent, config rôle AM.\n\n## Dépend de\n\n- Feed parent (widget partagé)\n- Module Cartes SYSTEM + API absences\n\n## À faire\n\n- Brancher le feed sur le TdB AM (coquille 3 col. quand prête)\n- Rôle AM :\n - lecture absences enfant\n - bulles **miroir** congé / arrêt en attente (clic → modifier dates tant qu’aucun parent n’a accepté)\n - après **refus parent** : bulle en tête + motif ; modifier & renvoyer **ou** supprimer (DELETE)\n - ack sur « absence enfant modifiée » (OK → carte disparaît)\n- CTA **« Ajouter une bulle »** → même modale générique (types émis par l’AM : congé, arrêt)\n- Pas de 2ᵉ implémentation de liste\n\n## Done when\n\n- AM voit la file du couple / enfant courant avec les actions de son rôle\n- Cycle congé : create → attente → accept/refus → modif éventuelle fonctionne en UI\n\n### Réf.\n\n- Mini-spec · découpage quotidien\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:47Z","updated_at":"2026-09-24T09:04:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":3},{"id":281,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/173","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/173","number":173,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Feed bulles / cartes (parent) — file d’attention","body":"## Contexte (ex-C2, recentré)\n\nEpic Quotidien. Colonne gauche = **file d’attention** (bulles), pas un historique de vie ni une messagerie.\n\nV1 focalisé **absences / congés / arrêt**. Sondages / sorties riches = plus tard.\n\n## Dépend de\n\n- API absences (liste / métiers)\n- Module Cartes SYSTEM (backend)\n\n## À faire\n\n- Widget **feed bulles** réutilisable (pastel, palette `frontend/assets/cards/` uniquement — 7 teintes)\n- Affichage selon statut : à traiter / en attente / refusé / traité (historique court grisé)\n- Actions parent : accepter / refuser congé (+ **modale motivation** si refus) ; ack arrêt maladie ; lecture absences\n- **Miroir créateur** : si le parent a déclaré une absence, il voit aussi sa bulle de suivi\n- Tri : actionnables / refusés en tête ; traités en bas puis purge (`purge_at`)\n- CTA **« Ajouter une bulle »** → ouvre la modale générique (ticket formulaire / modale)\n- Temps réel : se brancher sur le ticket realtime quand dispo (polling acceptable en attendant)\n\n## Hors scope\n\n- Layout desktop 2 panneaux (évolution hors 0.2.0)\n- Catalogue types optionnels / sondages\n\n## Done when\n\n- Parent voit et traite les bulles du couple courant\n- Refus congé exige une motivation\n- Colonne peut être peu remplie : normal (file d’attention)\n\n### Réf.\n\n- Mini-spec `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:46Z","updated_at":"2026-09-24T09:04:29Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":3},{"id":279,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/171","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/171","number":171,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Backend] API contexte enfants accueillis (AM)","body":"## Contexte (B3)\nSymétrique de #168 pour l’assistante maternelle.\n\n## À faire\nAPI listant les **enfants accueillis** / foyers liés à l’AM, avec données pour le bandeau couple (enfant + parent1/parent2 noms) et un **contexte courant** persistable. Payload cohérent avec #168 pour faciliter les widgets partagés.\n\n## Done when\nLe front AM peupple le sélecteur couple et le contexte sans mock hardcodé.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":59,"name":"api","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/59"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:45Z","updated_at":"2026-09-23T15:11:26Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":278,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/170","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/170","number":170,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Sélecteur couple enfant–parent(s) (AM)","body":"## Contexte (B2)\nCôté AM, le couple affiché n’est pas enfant|nounou mais **enfant | parent(s)**.\n\n## À faire\nRéutiliser le **même widget couple** que #167 en mode AM. Gauche : photo + nom de l’enfant. Droite : si **deux parents**, nom+prénom du parent 1 et en dessous nom+prénom du parent 2 (pas de photo parent obligatoire) ; si **un seul parent**, son nom+prénom **centré** dans la demi-zone. Dropdown si plusieurs enfants accueillis (bascule de contexte), sinon affichage informatif.\n\n## Done when\nL’AM identifie clairement l’enfant et le foyer (1 ou 2 parents) sans composant dupliqué.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:45Z","updated_at":"2026-09-23T15:11:26Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":277,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/169","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/169","number":169,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Front] Coquille TdB AM 3 colonnes + bandeau","body":"## Contexte (B1)\nLe dashboard AM est aujourd’hui un placeholder (`am_dashboard_screen.dart`). Il doit devenir le **miroir** du TdB parent.\n\n## À faire\nBrancher l’écran AM sur la **même coquille 3 colonnes + bandeau** livrée en #166 (TdB / Agenda / Contrat + menu). Injecter le rôle AM pour les droits et libellés. Ne **pas** recopier le layout : réutilisation obligatoire du widget/coquille.\n\n## Hors scope\nMétier cartes/blog/messagerie (tickets miroir C3, D3, E5) ; détail couple AM (#170).\n\n## Done when\nUne AM connectée voit la même structure 3 colonnes / look pastel que le parent, prête à recevoir les widgets partagés.\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-09-23T15:03:44Z","updated_at":"2026-09-23T21:40:40Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":273,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/165","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/165","number":165,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Epic] Quotidien parent / AM — TdB 3 colonnes, cartes, blog, messagerie","body":"## Contexte\nEpic du jalon **0.2.0 — Quotidien parent / AM**. Objectif : construire les espaces parent et assistante maternelle du quotidien (TdB 3 colonnes, cartes, blog, messagerie), avec un logiciel réel peuplé de données. L’« effet démo » = sync live multi-écrans (parent / AM / gestionnaire), pas un sous-MVP jetable.\n\n## Périmètre\n- Coquille TdB PC **3 colonnes** (cartes | blog défaut | messagerie) + bandeau TdB / Agenda / Contrat\n- Couple actif : enfant–nounou (parent) / enfant–parent(s) (AM)\n- Cartes : absences, congés AM, maladie AM, sorties (règles mini-spec §5)\n- Blog indispensable (auteurs AM + RPE) ; Mess. AM partagée foyer ; Mess. RPE privée + médiation\n- **Widgets partagés** parent ↔ AM (pas de double code) — voir mini-spec §2\n\n## Hors scope (volontaire)\nPage Contrat riche, agenda calendrier riche, notifs CDC fourre-tout, masquage messages, posts blog parent, carnet repas/sieste.\n\n## Tickets enfants\n**A** #166–#168 · **B** #169–#171 · **C** #172–#176 · **D** #177–#180 · **E** #181–#186 · **F** #187–#188 · **G** #189\n\n## Critères d’acceptation (effet démo)\n1. Même contexte de garde parent/AM · 2. Absence parent → carte AM · 3. Post blog AM → parent · 4. Annonce RPE visible · 5. Mess. AM sync · 6. Look papier/pastel\n\n---\n### Références (source de vérité)\n- Mini-spec : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`\n- Découpage : `docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md`\n- Maquette : `docs/maquettes/courantes/maquette-dashboard-parent-quotidien-v4.png`\n- Milestone : `0.2.0` · Epic : #165\n","ref":"","assets":[],"labels":[{"id":5,"name":"documentation","exclusive":false,"is_archived":false,"color":"0075ca","description":"Improvements or additions to documentation","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/5"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":51,"name":"ux","exclusive":false,"is_archived":false,"color":"e91e63","description":"UX/UI","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/51"},{"id":61,"name":"v0.2.0","exclusive":false,"is_archived":false,"color":"1abc9c","description":"Milestone quotidien parent/AM","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/61"}],"milestone":{"id":11,"title":"0.2.0","description":"Quotidien parent / AM\n\nTdB 3 colonnes (cartes | blog | messagerie), couples enfant–nounou / enfant–parent(s), blog AM+RPE, messagerie AM (foyer partagé) + RPE (privée / médiation).\n\nRéf. : docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md — docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md — maquettes/courantes/…-v4.png","state":"open","open_issues":23,"closed_issues":6,"created_at":"2025-11-28T09:58:27Z","updated_at":"2026-09-24T09:38:40Z","closed_at":null,"due_on":null},"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":1,"created_at":"2026-09-23T15:03:42Z","updated_at":"2026-09-24T09:02:53Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":258,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/150","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/150","number":150,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Full-stack] Fiche AM — combobox rattachement RPE (relais) sur l’onglet Enfants","body":"## Contexte\n\nSur la fiche AM (`AdminAmEditModal`), l’onglet **« Enfants accueillis »** gère les placements enfants, mais **aucun rattachement au RPE (relais)** n’est proposé.\n\nOr le modèle `utilisateurs.relais_id` existe déjà (utilisé pour les gestionnaires). Une AM doit pouvoir être rattachée à un **RPE / relais** depuis cette fiche.\n\n## Objectif\n\nSur le **3ᵉ onglet** de la modale AM, **au-dessus** de la section « Enfants accueillis », ajouter une **combobox « Rattachement RPE »** (liste des relais).\n\n## Frontend\n\n- [ ] Combobox / dropdown **RPE (relais)** en tête de l’onglet Enfants accueillis\n- [ ] Options : liste des relais existants (`GET /relais` ou service déjà utilisé pour les gestionnaires) + option « Aucun »\n- [ ] Préremplir avec le `relaisId` / `relaisNom` actuel de l’AM si présent\n- [ ] Modification → marque dirty → prise en compte au **Sauvegarder** (comme le reste de la fiche)\n- [ ] Placement UI : **avant** capacité / grille enfants\n\n## BDD\n\n**Pas de nouvelle table / colonne obligatoire** : `utilisateurs.relais_id` (FK → `relais`) existe déjà et peut servir pour une AM comme pour un gestionnaire.\n\nÀ faire éventuellement (léger) :\n- [ ] Clarifier côté modèle TypeORM : la relation `Relais.gestionnaires` est mal nommée (c’est en réalité *tous* les users liés) — renommer en `users` / `membres` si on touche au code\n- [ ] Aucune migration SQL bloquante pour le MVP de cette feature\n\n> Si un jour on veut un historique / multi-RPE AM, il faudrait une table de liaison dédiée — **hors scope 0.1.0**.\n\n## Backend / API — à faire évoluer\n\nAujourd’hui l’API fiche AM **ignore** le RPE :\n- `UpdateAmFicheAdminDto` : **pas** de `relaisId`\n- `updateFicheAdmin` : ne touche pas `user.relaisId`\n- `GET` AM : relations chargées sans `user.relais` (souvent juste `relais_id` brut si présent sur user)\n\nTravaux :\n- [ ] Ajouter `relaisId?: string | null` à `UpdateAmFicheAdminDto`\n- [ ] Dans `updateFicheAdmin`, persister `user.relaisId` (null = détacher du RPE)\n- [ ] Charger / exposer `user.relais` (id + nom) dans les réponses AM (`findOne` / `findAll` + mapper)\n- [ ] Vérifier `sanitizeUserForApi` / front `AssistanteMaternelleModel` pour lire `relaisId` / `relaisNom`\n- [ ] Réutiliser `GET /relais` existant pour peupler la combobox\n\n## Hors scope\n\n- Création d’un nouveau RPE depuis cette combobox (gestion relais = écran dédié)\n- Changement des règles métier gestionnaire / périmètre territorial au-delà du lien AM ↔ relais\n\n## Critères d'acceptation\n\n- [ ] Combobox visible en haut du 3ᵉ onglet, avant la gestion des enfants\n- [ ] Sélection d’un RPE + Sauvegarder → AM rattachée en BDD\n- [ ] Remise à « Aucun » + Sauvegarder → `relais_id` null\n- [ ] Rechargement fiche → valeur conservée\n\n## Milestone\n\n**0.1.0**\n\n## Références\n\n- Champ existant gestionnaires : `AdminUserFormDialog` / `relaisId`\n- Table `relais` + `utilisateurs.relais_id`\n","ref":"","assets":[],"labels":[{"id":56,"name":"admin","exclusive":false,"is_archived":false,"color":"9c27b0","description":"Administration","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/56"},{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":7,"name":"enhancement","exclusive":false,"is_archived":false,"color":"a2eeef","description":"New feature or request","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/7"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":55,"name":"gestionnaire","exclusive":false,"is_archived":false,"color":"ff9800","description":"Gestionnaire","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/55"},{"id":57,"name":"phase-1","exclusive":false,"is_archived":false,"color":"d32f2f","description":"Phase 1","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/57"},{"id":58,"name":"ui","exclusive":false,"is_archived":false,"color":"ededed","description":"","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/58"},{"id":60,"name":"v0.1.0","exclusive":false,"is_archived":false,"color":"207de5","description":"Issue rattachée au périmètre release 0.1.0","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/60"}],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":1,"created_at":"2026-07-17T14:17:07Z","updated_at":"2026-09-15T15:45:50Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":1},{"id":249,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/141","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/141","number":141,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Full-stack] Statut enfant — remplacer « actif » par gardé / sans garde (+ migration BDD)","body":"## Contexte\n\nLe type PostgreSQL `statut_enfant_type` et l’enum backend `StatutEnfantType` portent aujourd’hui **3 valeurs** :\n\n| Valeur API | Libellé actuel | Problème |\n|------------|----------------|----------|\n| `a_naitre` | À naître | OK |\n| `actif` | Actif | **Trop vague** — ne distingue pas enfant **gardé chez une AM** vs **chez lui sans AM** |\n| `scolarise` | Scolarisé | OK |\n\nRéf. schéma : `database/BDD.sql`, `backend/src/entities/children.entity.ts`, `docs/10_DATABASE.md`.\n\n## Objectif métier\n\nPasser à **4 statuts** qui reflètent la réalité du Relais :\n\n| Valeur API (proposition) | Libellé UI (proposition) | Signification |\n|--------------------------|--------------------------|---------------|\n| `a_naitre` | **À naître** | Enfant pas encore né (date prévue) |\n| `garde` | **Gardé** | Enfant actuellement en garde chez une assistante maternelle |\n| `sans_garde` | **Sans garde** | Enfant chez lui / chez ses responsables, **pas** placé chez une AM |\n| `scolarise` | **Scolarisé** | Enfant scolarisé (hors garde AM au sens Relais) |\n\n\n## Backend\n\n- [ ] Migration SQL `ALTER TYPE statut_enfant_type` (ajout valeurs + migration données + retrait `actif`)\n- [ ] Enum `StatutEnfantType` + DTOs Swagger (`CreateEnfantsDto`, `UpdateEnfantsDto`, dossier famille #119)\n- [ ] Règles métier : `auth.service` (inscription / reprise #112) — aujourd’hui `actif` si date naissance, `a_naitre` sinon\n- [ ] Messages d’erreur (`Un enfant actif doit avoir une date de naissance` → adapter)\n- [ ] Tests unitaires\n\n### Migration données (à cadrer)\n\n| Ancien | Proposition par défaut | Commentaire |\n|--------|------------------------|-------------|\n| `a_naitre` | `a_naitre` | inchangé |\n| `actif` | **`sans_garde`** | **Valeur par défaut** à la migration (libellé « Sans garde ») |\n| `scolarise` | `scolarise` | inchangé |\n\n## Frontend\n\n- [ ] Inscription parent (étape enfants), reprise #112, validation wizard\n- [ ] Dashboard admin : filtres onglet Enfants (#137), fiche enfant (#138), modales #140\n- [ ] Libellés accordés au genre si besoin (comme `scolarise` dans `validation_family_wizard.dart`)\n\n## Documentation\n\n- [ ] `docs/10_DATABASE.md`, `database/docs/ENUMS.md`\n- [ ] `EVOLUTIONS_CDC.md` ou CDC § statut enfant si applicable\n\n## Critères d’acceptation\n\n1. Plus aucune référence à `actif` pour le statut enfant (API + UI + BDD).\n2. Les 4 statuts sont sélectionnables en admin et à l’inscription.\n3. Données existantes migrées sans perte (script + vérif comptages avant/après).\n4. Swagger et tests à jour.\n\n## Hors scope\n\n- Lien automatique statut ↔ contrat AM (futur : statut `garde` déduit d’un contrat actif ?)\n- Qualification responsable–enfant (#139)\n\n## Branche suggérée\n\n`feature/141-statut-enfant-garde-sans-garde` depuis `develop`","ref":"","assets":[],"labels":[],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":2,"created_at":"2026-06-21T15:46:21Z","updated_at":"2026-06-24T20:50:02Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":2},{"id":247,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/139","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/139","number":139,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Epic] Parcours gestionnaire — famille complexe (N responsables, visibilité par enfant)","body":"## Contexte\n\nDoc `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` § 4.4 et § 7.5.\n\nCas type : M + A → α, M + B → β. **M** doit avoir **α et β sur le même compte** ; **A** ne voit que α, **B** que β.\n\n## Objectif\n\nParcours de **création réservé au gestionnaire** (pas inscription publique) pour constituer une configuration avec **plus de 2 responsables/tuteurs**, sans se limiter au champ `co_parent`, en s'appuyant sur `enfants_parents`.\n\n## Règles clés\n\n- Visibilité parent = enfants liés via `enfants_parents` uniquement\n- Workflows (validation, refus, reprise) sans fusion `getFamilyUserIds`\n- Composition libre : cocher responsables par enfant\n\n## Dépendances\n\n- #115 / #116 affiliation\n- #129 création admin (extension)\n- Refonte visibilité API parent + workflows\n\n## Hors scope v1.0.0\n\nContournement actuel : 2ᵉ email pour M (doc 28 § 4.2).","ref":"","assets":[],"labels":[],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-06-16T22:31:00Z","updated_at":"2026-07-24T15:51:23Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":236,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/128","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/128","number":128,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Full-stack] Audit / traçabilité des modifications dossier et profils (parent/AM)","body":"## Contexte\n\nPour la sécurité et le suivi des dossiers (familles et assistantes maternelles), il faut pouvoir **savoir qui a modifié quoi, quand** sur les données d'un dossier ou d'un profil. C'est essentiel en cas de **litige ou désaccord entre co-parents**, de support / audit, et pour la conformité minimale.\n\nAujourd'hui :\n- Certaines entités ont déjà des timestamps **`cree_le` / `modifie_le`** (`dossier_famille`, `utilisateurs`), mais ils ne disent pas **qui** a modifié, ni **quels champs**, ni les **événements transverses** (refus, validation, fusion de comptes…).\n- Il n'existe **aucun journal d'audit** structuré.\n\n## Objectif\n\nMettre en place un **journal d'audit** (qui / quand / quoi) couvrant :\n\n- **Dossier famille** (parents, enfants, présentation / motivation).\n- **Dossier assistante maternelle** (identité, infos pro, photo, champs métier).\n\n…sans logger tout le bruit applicatif.\n\n## Périmètre & règles produit\n\n### 1. Règle simple — alignée inscription\n\nÀ **chaque modification** d'une information **fournie lors de la création** du dossier **parent** ou **AM**, un événement est **loggé**. La liste exacte est dérivée des champs persistés à l'inscription (`RegisterParentCompletDto`, `RegisterAMCompletDto` et entités liées), ce qui couvre notamment :\n\n- identité, coordonnées (adresse, téléphone, ville, code postal, …),\n- **photo** (parent, AM, enfants),\n- enfants (parcours parent : prénom/nom, dates, genre, consentement, …),\n- texte de présentation / motivation,\n- champs métier AM (NIR, agrément, capacité d'accueil, places dispo, biographie, …).\n\n**Exclus par défaut** (sauf décision ultérieure) : lectures, listings, requêtes purement techniques, heartbeats, etc.\n\n### 2. Actions structurelles et événements métier (toujours loggés)\n\n- **Association / fusion de deux comptes**, **rattachement co-parent**, **affectation ou changement de `numero_dossier`**, tout regroupement ou scission identifiable → événement dédié (qui, quand, identifiants concernés, type d'opération).\n- **Cycle de vie** dossier / compte : **refus**, **validation**, **suspension** (et équivalents) → une ligne d'audit par action, pour une **timeline lisible** côté gestionnaire.\n\n## Architecture proposée\n\n### Journal d'audit en BDD (recommandé vs simple fichier)\n\n- Permet requêtes / filtres par `numero_dossier` ou `user_id`, sauvegardes PG, cohérence transactionnelle.\n- **Colonnes typiques** (à figer en spec technique) :\n - `id`\n - `occurred_at` (timestamptz)\n - `actor_user_id` (nullable si action système) + `actor_role`\n - `numero_dossier` (dénormalisé pour filtrage)\n - `entity_type` (`utilisateur` | `parent` | `assistante_maternelle` | `enfant` | `dossier_famille` | …)\n - `entity_id`\n - `action` (`create` | `update` | `delete` | événement métier : `refus`, `validation`, `suspension`, `fusion_comptes`, `affectation_numero_dossier`, …)\n - `changes` JSON (ancien / nouveau ou patch, ou résumé pour les événements)\n - optionnels : `request_id`, `ip`, `source` (`gestionnaire_ui`, `selfservice_reprise`, …)\n- Écriture **dans la même transaction** que la modification métier (ou outbox si async).\n\n### Colonnes « création / dernière modification »\n\n- Ne **pas dupliquer** sur chaque table : `cree_le` / `modifie_le` existent déjà sur `dossier_famille` et `utilisateurs`.\n- Faire un **inventaire** des entités du dossier (parents, enfants, AM, pièces jointes…) et **compléter** uniquement où il manque des `@UpdateDateColumn` / triggers.\n- Si besoin d'une **« dernière modification dossier »** unique par `numero_dossier` : soit **vue / requête** sur `max(modifie_le)`, soit colonne dérivée sur une entité pivot — à trancher en spec.\n\n### Fichier\n\nRéservé éventuellement en **complément** (export append-only, intégration SI) ; **pas** comme source unique si l'app affiche l'historique.\n\n## Accès gestionnaire (exigence métier)\n\n- Le **gestionnaire** doit pouvoir consulter **facilement** l'historique des modifications d'un dossier (`numero_dossier`), notamment **en cas de litige entre co-parents**.\n- **Backend** : endpoint dédié (ex. `GET …/dossiers/:numeroDossier/audit` ou sous-ressource des routes gestionnaire existantes), pagination, tri par date, contrôle d'accès **gestionnaire / admin** (même périmètre que la validation dossier).\n- **Frontend** : entrée visible depuis le parcours d'examen d'un dossier (wizard / fiche dossier) — onglet ou panneau **« Historique des modifications »**, pas une page cachée.\n- **Contenu affiché minimum** : date / heure, acteur (identité + rôle), nature du changement (champs ou résumé JSON lisible) ; distinction claire **parent 1 / parent 2 / AM / gestionnaire / admin**.\n\n## Visibilité parents & AM (phase suivante)\n\nÀ terme, lorsque les **parents** et les **AM** auront leur **tableau de bord**, un **sous-menu discret** (non mis en avant, mais accessible) donnera accès au **même historique** côté demandeur, **incluant toutes** les lignes d'audit (gestionnaire, admin, parent, AM).\n\n→ Le ticket peut être livré en deux temps si nécessaire : **MVP** (API + écran gestionnaire), **phase dashboard** (entrée parent/AM dans leur tableau de bord, dépend de l'avancée des dashboards).\n\n## Points transverses\n\n- **Sécurité / secrets** : exigence implicite — **pas** de mots de passe, tokens en clair, etc. dans les payloads d'audit.\n- **RGPD / rétention** : durée de conservation des lignes d'audit, anonymisation à la suppression compte → **pas urgent pour la V1**, peut faire l'objet d'une issue dédiée.\n- **Export CSV / PDF** gestionnaire : **hors périmètre V1** — ticket séparé en phase 2.\n- **Alignement** avec les flux **modification dossier** (en attente + refusé, mêmes DTO / même `apply…(context)`) : l'audit doit recevoir un `source` ou `correlation_id` pour distinguer gestionnaire vs demandeur.\n\n## Critères de done (V1)\n\n- [ ] Migration / mise à jour [`database/BDD.sql`](database/BDD.sql) : table d'audit + index utiles (`numero_dossier`, `occurred_at`, `actor_user_id`).\n- [ ] Inventaire & complétion `@CreateDateColumn` / `@UpdateDateColumn` manquants sur les entités du dossier.\n- [ ] Service applicatif `appendAuditLog(...)` (ou équivalent) branché sur :\n - création / modification des champs « inscription parent / AM » ;\n - refus, validation, suspension ;\n - affectation / changement `numero_dossier`, fusion / association de comptes.\n- [ ] API `GET …/dossiers/:numeroDossier/audit` (gestionnaire / admin) — pagination, tri par date.\n- [ ] Écran / panneau **gestionnaire** « Historique des modifications » sur la fiche dossier (famille et AM).\n- [ ] Tests unitaires / intégration sur les chemins critiques.\n- [ ] OpenAPI à jour.\n\n## Hors périmètre (à scinder en autres tickets si besoin)\n\n- Export CSV / PDF de l'historique.\n- Durée de rétention RGPD + anonymisation à la suppression compte.\n- Entrée « historique » dans le tableau de bord parent / AM (dépend des dashboards).\n","ref":"","assets":[],"labels":[{"id":33,"name":"backend","exclusive":false,"is_archived":false,"color":"2ecc71","description":"Backend NestJS","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/33"},{"id":34,"name":"database","exclusive":false,"is_archived":false,"color":"f39c12","description":"Base de données PostgreSQL","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/34"},{"id":32,"name":"frontend","exclusive":false,"is_archived":false,"color":"3498db","description":"Frontend Flutter/Web","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/32"},{"id":55,"name":"gestionnaire","exclusive":false,"is_archived":false,"color":"ff9800","description":"Gestionnaire","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/55"},{"id":38,"name":"p2","exclusive":false,"is_archived":false,"color":"f1c40f","description":"Priorité 2","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/38"},{"id":57,"name":"phase-1","exclusive":false,"is_archived":false,"color":"d32f2f","description":"Phase 1","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/57"},{"id":44,"name":"rgpd","exclusive":false,"is_archived":false,"color":"16a085","description":"RGPD/Conformité","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/44"},{"id":43,"name":"security","exclusive":false,"is_archived":false,"color":"8e44ad","description":"Sécurité","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/43"},{"id":60,"name":"v0.1.0","exclusive":false,"is_archived":false,"color":"207de5","description":"Issue rattachée au périmètre release 0.1.0","url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels/60"}],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-05-13T14:55:16Z","updated_at":"2026-07-17T15:05:00Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":234,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/126","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/126","number":126,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Bug][Regression] Upload documents légaux retourne 500 (lié #33)","body":"## Contexte\nLe front obtient un `500` sur `POST /api/v1/documents-legaux` alors que le ticket #33 est fermé.\n\n## Symptôme observé\n- Requête: `POST /api/v1/documents-legaux` (multipart `type` + `file`)\n- Réponse: `500`\n- Traefik confirme la requête vers `ptitspas-api@docker` avec statut 500.\n\n## Hypothèse technique\nDans `DocumentsLegauxController`, l'upload utilise encore un `userId` placeholder (`00000000-0000-0000-0000-000000000000`).\nLe service tente ensuite d'enregistrer `televersePar` avec cet ID, ce qui peut casser la contrainte FK et produire un 500.\n\n## Attendu\n- Pas de 500 sur upload valide.\n- Si utilisateur non authentifié/non résolu, gérer explicitement (fallback sans `televersePar` ou 4xx clair).\n- Erreurs métier explicites (400/401/403) plutôt qu'erreur interne.\n\n## Liens\n- Régression liée à #33\n- Impact fonctionnel: test front montée documents légaux bloqué","ref":"","assets":[],"labels":[],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-04-17T10:43:09Z","updated_at":"2026-04-17T15:35:37Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0},{"id":233,"url":"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/125","html_url":"https://git.ptits-pas.fr/jmartin/petitspas/issues/125","number":125,"user":{"id":2,"login":"jmartin","login_name":"","source_id":0,"full_name":"MARTIN Julien","email":"julien.martin@ptits-pas.fr","avatar_url":"https://git.ptits-pas.fr/avatars/cdc397d2522aca19e6a3ad88f54d2fd140c481810d2af29ec8d516700e73d107","html_url":"https://git.ptits-pas.fr/jmartin","language":"fr-FR","is_admin":false,"last_login":"2026-09-09T13:44:59Z","created":"2025-05-19T20:13:48Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":1,"starred_repos_count":1,"username":"jmartin"},"original_author":"","original_author_id":0,"title":"[Tech] Contrôle d’accès téléchargement photo","body":"Objectif: endpoint sécurisé de téléchargement photo avec check de droits centralisé (policy/service).\\n\\nCritères:\\n- Endpoint sécurisé (réf #57)\\n- Contrôle d’accès par rôle et relation métier (parent/AM/gestionnaire)\\n- Remplacement progressif de l’accès statique non contrôlé\\n- Tests d’autorisation nominaux + refus\\n\\nContexte: sécurité/RGPD minimal requis pour 0.1.0.","ref":"","assets":[],"labels":[],"milestone":null,"projects":null,"assignee":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-04-16T08:45:54Z","updated_at":"2026-09-15T15:45:50Z","closed_at":null,"due_date":null,"time_estimate":0,"pull_request":null,"repository":{"id":5,"name":"petitspas","owner":"jmartin","full_name":"jmartin/petitspas"},"pin_order":0,"content_version":0}] \ No newline at end of file