diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff54b1e..64f08a3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,7 +16,8 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters'; 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 { EvenementsAgendaModule } from './modules/evenements-agenda'; +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'; @@ -57,7 +58,8 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module'; AuthModule, AppConfigModule, DocumentsLegauxModule, - AbsencesGardeModule, + EvenementsAgendaModule, + 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..b02906a --- /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 { EvenementsAgenda } from './evenements_agenda.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_evenement', type: 'uuid', nullable: true }) + id_evenement?: string; + + @ManyToOne(() => EvenementsAgenda, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'id_evenement', referencedColumnName: 'id' }) + evenement?: EvenementsAgenda; + + @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..9047f4f --- /dev/null +++ b/backend/src/entities/card_responses.entity.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + RelationId, +} 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; + + /** FK carte — une seule source de vérité via @JoinColumn (pas de @Column doublon). */ + @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) + card: CardInstance; + + @RelationId((r: CardResponse) => r.card) + id_card: string; + + @ManyToOne(() => Users, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) + user: Users; + + @RelationId((r: CardResponse) => r.user) + id_utilisateur: string; + + @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/entities/absences_garde.entity.ts b/backend/src/entities/evenements_agenda.entity.ts similarity index 72% rename from backend/src/entities/absences_garde.entity.ts rename to backend/src/entities/evenements_agenda.entity.ts index 4799a17..33b693b 100644 --- a/backend/src/entities/absences_garde.entity.ts +++ b/backend/src/entities/evenements_agenda.entity.ts @@ -10,25 +10,25 @@ import { import { AmChildren } from './am_children.entity'; import { Users } from './users.entity'; -export enum TypeAbsenceGardeType { +export enum TypeEvenementAgendaType { ABSENCE_ENFANT = 'absence_enfant', CONGE_AM = 'conge_am', ARRET_MALADIE_AM = 'arret_maladie_am', } -export enum StatutAbsenceGardeType { +export enum StatutEvenementAgendaType { EN_ATTENTE = 'en_attente', ACCEPTE = 'accepte', REFUSE = 'refuse', } /** - * Vérité métier des absences / congés / arrêts par couple AM↔enfant. - * Les cartes (module ultérieur) collectent ; cette table stocke. - * Ticket #193. + * Vérité métier agenda par couple AM↔enfant (placement). + * Types V1 : absences / congés / arrêts ; extensible (soins, école, mairie…). + * Les cartes collectent ; cette table historise. Ticket #205. */ -@Entity('absences_garde', { schema: 'public' }) -export class AbsencesGarde { +@Entity('evenements_agenda', { schema: 'public' }) +export class EvenementsAgenda { @PrimaryGeneratedColumn('uuid') id: string; @@ -41,11 +41,11 @@ export class AbsencesGarde { @Column({ type: 'enum', - enum: TypeAbsenceGardeType, - enumName: 'type_absence_garde_type', + enum: TypeEvenementAgendaType, + enumName: 'type_evenement_agenda_type', name: 'type', }) - type: TypeAbsenceGardeType; + type: TypeEvenementAgendaType; @Column({ name: 'date_debut', type: 'date' }) date_debut: string; @@ -55,12 +55,12 @@ export class AbsencesGarde { @Column({ type: 'enum', - enum: StatutAbsenceGardeType, - enumName: 'statut_absence_garde_type', + enum: StatutEvenementAgendaType, + enumName: 'statut_evenement_agenda_type', name: 'statut', - default: StatutAbsenceGardeType.EN_ATTENTE, + default: StatutEvenementAgendaType.EN_ATTENTE, }) - statut: StatutAbsenceGardeType; + statut: StatutEvenementAgendaType; /** Purge auto pour en_attente/refuse ; accepte → infinity. */ @Column({ name: 'expire_at', type: 'timestamptz' }) 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..ce1e599 --- /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 evenements_agenda) — #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..e7295d1 --- /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 { EvenementsAgendaModule } from '../evenements-agenda'; +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: [ + EvenementsAgendaModule, + 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..cfdb925 --- /dev/null +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -0,0 +1,284 @@ +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 { EvenementsAgendaService } from '../evenements-agenda/evenements-agenda.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 { StatutEvenementAgendaType, TypeEvenementAgendaType } from 'src/entities/evenements_agenda.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() })), + update: jest.fn(async () => ({ affected: 1 })), + 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(async (x) => x), + insert: jest.fn(), + }; + const amChildrenRepo = { findOne: jest.fn() }; + const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; + const evenementsService = { + 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: EvenementsAgendaService, useValue: evenementsService }, + { 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, + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + evenementsService.creer.mockResolvedValue({ + id: 'abs-1', + type: TypeEvenementAgendaType.CONGE_AM, + statut: StatutEvenementAgendaType.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_evenement: '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', + 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(evenementsService.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); + }); + + it('parent crée absence_enfant → audience AM + co-parent', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'absence_enfant', + system: true, + titre: 'Absence enfant', + emitter_roles: [RoleType.PARENT], + recipient_roles: [RoleType.ASSISTANTE_MATERNELLE], + audience_resolver: 'couple_am', + response_mode: CardResponseModeType.NONE, + retention_days: 7, + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + parentsChildrenRepo.find.mockResolvedValue([ + { parentId: 'p-1', enfantId: 'e-1' }, + { parentId: 'p-2', enfantId: 'e-1' }, + ]); + evenementsService.creer.mockResolvedValue({ + id: 'abs-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + statut: StatutEvenementAgendaType.ACCEPTE, + }); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-abs', + type_code: 'absence_enfant', + id_placement: 'pl-1', + id_evenement: 'abs-1', + cree_par: 'p-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-10-28', date_fin: '2026-10-29' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Absence enfant', + response_mode: CardResponseModeType.NONE, + retention_days: 7, + }, + responses: [], + audience: [ + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + + await service.creer('p-1', RoleType.PARENT, { + type_code: 'absence_enfant', + id_placement: 'pl-1', + date_debut: '2026-10-28', + date_fin: '2026-10-29', + }); + + const saved = audienceRepo.save.mock.calls[0][0] as Array<{ + id_utilisateur: string; + is_creator: boolean; + }>; + const ids = saved.map((m) => m.id_utilisateur).sort(); + expect(ids).toEqual(['am-1', 'p-1', 'p-2']); + expect(saved.find((m) => m.id_utilisateur === 'p-2')?.is_creator).toBe(false); + }); + + it('parent accepte conge_am → response via relation + carte traitee', async () => { + cardsRepo.findOne.mockResolvedValue({ + id: 'card-1', + type_code: 'conge_am', + id_placement: 'pl-1', + id_evenement: 'ev-1', + cree_par: 'am-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-10-27', date_fin: '2026-10-31' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Congé AM', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + }, + responses: [], + audience: [ + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + audienceRepo.findOne.mockResolvedValue({ id_card: 'card-1', id_utilisateur: 'p-1' }); + evenementsService.maj.mockResolvedValue({}); + + const res = await service.repondre('p-1', RoleType.PARENT, 'card-1', { + action: 'accept' as never, + }); + + expect(responsesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + card: { id: 'card-1' }, + user: { id: 'p-1' }, + action: 'accept', + }), + ); + expect(responsesRepo.save).toHaveBeenCalled(); + expect(evenementsService.maj).toHaveBeenCalledWith( + 'p-1', + RoleType.PARENT, + 'ev-1', + expect.objectContaining({ statut: StatutEvenementAgendaType.ACCEPTE }), + ); + expect(cardsRepo.update).toHaveBeenCalledWith( + { id: 'card-1' }, + expect.objectContaining({ statut: CardInstanceStatutType.TRAITEE }), + ); + expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); + }); +}); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts new file mode 100644 index 0000000..b0402a2 --- /dev/null +++ b/backend/src/modules/cards/cards.service.ts @@ -0,0 +1,534 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { EvenementsAgendaService } from '../evenements-agenda/evenements-agenda.service'; +import { + StatutEvenementAgendaType, + TypeEvenementAgendaType, +} from 'src/entities/evenements_agenda.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 evenementsService: EvenementsAgendaService, + private readonly realtime: CardsRealtimeService, + ) {} + + async listerTypes(role: RoleType): Promise< + Pick< + CardType, + | 'code' + | 'system' + | 'titre' + | 'emitter_roles' + | 'recipient_roles' + | 'audience_resolver' + | 'response_mode' + | 'retention_days' + >[] + > { + const all = await this.typesRepo.find({ where: { system: true } }); + return all + .filter((t) => t.emitter_roles.includes(role)) + .map((t) => ({ + code: t.code, + system: t.system, + titre: t.titre, + emitter_roles: t.emitter_roles, + recipient_roles: t.recipient_roles, + audience_resolver: t.audience_resolver, + response_mode: t.response_mode, + retention_days: t.retention_days, + })); + } + + 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_evenement; + const absenceType = this.mapAbsenceType(dto.type_code); + + if (operation === CardOperationType.CREATE) { + const absence = await this.evenementsService.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_evenement requis pour operation=update'); + } + await this.evenementsService.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: StatutEvenementAgendaType.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_evenement: 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({ + card: { id: card.id }, + user: { id: userId }, + action: dto.action, + comment: dto.comment?.trim() || undefined, + }), + ); + + if (card.id_evenement) { + if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { + await this.evenementsService.maj(userId, role, card.id_evenement, { + statut: StatutEvenementAgendaType.ACCEPTE, + }); + card.statut = CardInstanceStatutType.TRAITEE; + } else if (dto.action === CardResponseActionType.REFUSE) { + await this.evenementsService.maj(userId, role, card.id_evenement, { + statut: StatutEvenementAgendaType.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); + // Ne pas cardsRepo.save(card) avec relations chargées : TypeORM peut + // resynchroniser les FK enfants (UPDATE … SET id_card=null). + await this.cardsRepo.update( + { id: card.id }, + { statut: card.statut, purge_at: card.purge_at }, + ); + 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_evenement) { + await this.evenementsService.maj(userId, role, card.id_evenement, { + date_debut: debut, + date_fin: fin, + motif: dto.motif, + statut: StatutEvenementAgendaType.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.manager.query( + `UPDATE card_instances + SET statut = $1, purge_at = $2, payload = $3::jsonb, modifie_le = now() + WHERE id = $4`, + [card.statut, card.purge_at, JSON.stringify(card.payload), card.id], + ); + 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_evenement) { + const absStatut = + card.statut === CardInstanceStatutType.TRAITEE + ? null + : card.id_evenement; + // Supprime l’absence liée si pas encore acceptée définitivement + if ( + card.statut === CardInstanceStatutType.OUVERTE || + card.statut === CardInstanceStatutType.REFUSEE + ) { + await this.evenementsService.supprimer(userId, role, card.id_evenement); + } + 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): TypeEvenementAgendaType { + if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') { + return TypeEvenementAgendaType.ABSENCE_ENFANT; + } + if (typeCode === 'conge_am') return TypeEvenementAgendaType.CONGE_AM; + if (typeCode === 'arret_maladie_am') { + return TypeEvenementAgendaType.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 evenements_agenda 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') { + // Destinataire métier = AM + miroir foyer : tous les co-parents de l’enfant + members.push({ + id_card: card.id, + id_utilisateur: placement.amId, + role_snapshot: RoleType.ASSISTANTE_MATERNELLE, + is_creator: false, + }); + const liensAm = await this.parentsChildrenRepo.find({ + where: { enfantId: placement.enfantId }, + }); + for (const l of liensAm) { + if (l.parentId === creatorId) continue; + members.push({ + id_card: card.id, + id_utilisateur: l.parentId, + role_snapshot: RoleType.PARENT, + 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, + id_placement: card.id_placement, + id_evenement: card.id_evenement ?? 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..d086dd5 --- /dev/null +++ b/backend/src/modules/cards/dto/cards.dto.ts @@ -0,0 +1,134 @@ +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 evenements_agenda)', + }) + @IsOptional() + @IsUUID() + id_evenement?: 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; + + @ApiProperty() + id_placement: string; + + @ApiPropertyOptional() + id_evenement?: 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/modules/evenements-agenda/dto/evenements-agenda.dto.ts b/backend/src/modules/evenements-agenda/dto/evenements-agenda.dto.ts new file mode 100644 index 0000000..ac93684 --- /dev/null +++ b/backend/src/modules/evenements-agenda/dto/evenements-agenda.dto.ts @@ -0,0 +1,119 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; +import { + StatutEvenementAgendaType, + TypeEvenementAgendaType, +} from 'src/entities/evenements_agenda.entity'; + +export class CreerEvenementAgendaDto { + @ApiProperty({ description: 'Placement AM↔enfant (couple)' }) + @IsUUID() + id_placement: string; + + @ApiProperty({ enum: TypeEvenementAgendaType }) + @IsEnum(TypeEvenementAgendaType) + type: TypeEvenementAgendaType; + + @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; +} + +export class MajEvenementAgendaDto { + @ApiPropertyOptional({ example: '2026-10-02' }) + @IsOptional() + @IsDateString() + date_debut?: string; + + @ApiPropertyOptional({ example: '2026-10-06' }) + @IsOptional() + @IsDateString() + date_fin?: string; + + @ApiPropertyOptional({ enum: StatutEvenementAgendaType }) + @IsOptional() + @IsEnum(StatutEvenementAgendaType) + statut?: StatutEvenementAgendaType; + + @ApiPropertyOptional({ + description: 'Motivation de refus (parent) ou motif créateur', + }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(2000) + motif?: string; +} + +export class EvenementAgendaDto { + @ApiProperty() + id: string; + + @ApiProperty() + id_placement: string; + + @ApiProperty({ enum: TypeEvenementAgendaType }) + type: TypeEvenementAgendaType; + + @ApiProperty() + date_debut: string; + + @ApiProperty() + date_fin: string; + + @ApiProperty({ enum: StatutEvenementAgendaType }) + statut: StatutEvenementAgendaType; + + @ApiProperty() + expire_at: string; + + @ApiPropertyOptional() + cree_par?: string | null; + + @ApiPropertyOptional() + motif?: string | null; + + @ApiPropertyOptional() + id_enfant?: string | null; + + @ApiPropertyOptional() + prenom_enfant?: string | null; + + @ApiPropertyOptional() + id_am?: string | null; + + @ApiPropertyOptional() + prenom_am?: string | null; + + @ApiPropertyOptional() + nom_am?: string | null; + + @ApiProperty() + cree_le: string; + + @ApiProperty() + modifie_le: string; +} + +export class ListeEvenementsAgendaDto { + @ApiProperty({ type: [EvenementAgendaDto] }) + items: EvenementAgendaDto[]; +} diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.controller.spec.ts b/backend/src/modules/evenements-agenda/evenements-agenda.controller.spec.ts new file mode 100644 index 0000000..7769f5a --- /dev/null +++ b/backend/src/modules/evenements-agenda/evenements-agenda.controller.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EvenementsAgendaController } from './evenements-agenda.controller'; +import { EvenementsAgendaService } from './evenements-agenda.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { RoleType } from 'src/entities/users.entity'; +import { TypeEvenementAgendaType } from 'src/entities/evenements_agenda.entity'; + +describe('EvenementsAgendaController (#172)', () => { + let controller: EvenementsAgendaController; + const serviceMock = { + lister: jest.fn(), + creer: jest.fn(), + maj: jest.fn(), + supprimer: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EvenementsAgendaController], + providers: [{ provide: EvenementsAgendaService, useValue: serviceMock }], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(EvenementsAgendaController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('lister délègue au service', async () => { + serviceMock.lister.mockResolvedValue({ items: [] }); + const res = await controller.lister( + 'u1', + RoleType.PARENT, + 'pl-1', + undefined, + undefined, + '2026-01-01', + '2026-12-31', + ); + expect(serviceMock.lister).toHaveBeenCalledWith('u1', RoleType.PARENT, { + placementId: 'pl-1', + type: undefined, + statut: undefined, + from: '2026-01-01', + to: '2026-12-31', + }); + expect(res.items).toEqual([]); + }); + + it('creer délègue au service', async () => { + serviceMock.creer.mockResolvedValue({ id: 'a1' }); + const dto = { + id_placement: 'pl-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + }; + await controller.creer('u1', RoleType.PARENT, dto); + expect(serviceMock.creer).toHaveBeenCalledWith( + 'u1', + RoleType.PARENT, + dto, + ); + }); +}); diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.controller.ts b/backend/src/modules/evenements-agenda/evenements-agenda.controller.ts new file mode 100644 index 0000000..46a037a --- /dev/null +++ b/backend/src/modules/evenements-agenda/evenements-agenda.controller.ts @@ -0,0 +1,124 @@ +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 { + StatutEvenementAgendaType, + TypeEvenementAgendaType, +} from 'src/entities/evenements_agenda.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { EvenementsAgendaService } from './evenements-agenda.service'; +import { + EvenementAgendaDto, + CreerEvenementAgendaDto, + ListeEvenementsAgendaDto, + MajEvenementAgendaDto, +} from './dto/evenements-agenda.dto'; + +@ApiTags('Événements agenda') +@ApiBearerAuth('access-token') +@Controller('evenements-agenda') +@UseGuards(AuthGuard, RolesGuard) +export class EvenementsAgendaController { + constructor(private readonly evenementsAgendaService: EvenementsAgendaService) {} + + @Get() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Lister les absences / congés / arrêts — ticket #205 / ex-#172', + description: + 'Sans placementId : tous les placements du user (parent = famille multi-AM ; AM = tous accueils). ' + + 'Avec placementId : un couple enfant–AM.', + }) + @ApiQuery({ name: 'placementId', required: false }) + @ApiQuery({ name: 'type', required: false, enum: TypeEvenementAgendaType }) + @ApiQuery({ name: 'statut', required: false, enum: StatutEvenementAgendaType }) + @ApiQuery({ name: 'from', required: false, description: 'YYYY-MM-DD' }) + @ApiQuery({ name: 'to', required: false, description: 'YYYY-MM-DD' }) + @ApiResponse({ status: 200, type: ListeEvenementsAgendaDto }) + lister( + @User('id') userId: string, + @User('role') role: RoleType, + @Query('placementId') placementId?: string, + @Query('type') type?: TypeEvenementAgendaType, + @Query('statut') statut?: StatutEvenementAgendaType, + @Query('from') from?: string, + @Query('to') to?: string, + ): Promise { + return this.evenementsAgendaService.lister(userId, role, { + placementId, + type, + statut, + from, + to, + }); + } + + @Post() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Créer une période d’absence / congé / arrêt — #172' }) + @ApiBody({ type: CreerEvenementAgendaDto }) + @ApiResponse({ status: 201, type: EvenementAgendaDto }) + @HttpCode(HttpStatus.CREATED) + creer( + @User('id') userId: string, + @User('role') role: RoleType, + @Body() dto: CreerEvenementAgendaDto, + ): Promise { + return this.evenementsAgendaService.creer(userId, role, dto); + } + + @Patch(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Mettre à jour dates / statut / motif — #172', + description: + 'Parent : accept/refuse congé (motivation si refus), ack arrêt. ' + + 'AM : modifier dates (en attente / refuse / accepté), republication après refus.', + }) + @ApiBody({ type: MajEvenementAgendaDto }) + @ApiResponse({ status: 200, type: EvenementAgendaDto }) + maj( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MajEvenementAgendaDto, + ): Promise { + return this.evenementsAgendaService.maj(userId, role, id, dto); + } + + @Delete(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Supprimer une absence (hard delete) — #172' }) + @ApiResponse({ status: 204 }) + async supprimer( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.evenementsAgendaService.supprimer(userId, role, id); + } +} diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.module.ts b/backend/src/modules/evenements-agenda/evenements-agenda.module.ts new file mode 100644 index 0000000..50d8de6 --- /dev/null +++ b/backend/src/modules/evenements-agenda/evenements-agenda.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EvenementsAgenda } from 'src/entities/evenements_agenda.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { EvenementsAgendaController } from './evenements-agenda.controller'; +import { EvenementsAgendaService } from './evenements-agenda.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EvenementsAgenda, AmChildren, ParentsChildren]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), + ], + controllers: [EvenementsAgendaController], + providers: [EvenementsAgendaService], + exports: [EvenementsAgendaService], +}) +export class EvenementsAgendaModule {} diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.service.spec.ts b/backend/src/modules/evenements-agenda/evenements-agenda.service.spec.ts new file mode 100644 index 0000000..e14e59b --- /dev/null +++ b/backend/src/modules/evenements-agenda/evenements-agenda.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ForbiddenException, BadRequestException } from '@nestjs/common'; +import { EvenementsAgendaService } from './evenements-agenda.service'; +import { + EvenementsAgenda, + StatutEvenementAgendaType, + TypeEvenementAgendaType, +} from 'src/entities/evenements_agenda.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; + +describe('EvenementsAgendaService (#172)', () => { + let service: EvenementsAgendaService; + const absencesRepo = { + create: jest.fn((x) => x), + save: jest.fn(async (x) => ({ + ...x, + id: x.id ?? 'abs-1', + cree_le: new Date(), + modifie_le: new Date(), + })), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + const amChildrenRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + const parentsChildrenRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EvenementsAgendaService, + { provide: getRepositoryToken(EvenementsAgenda), useValue: absencesRepo }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, + { + provide: getRepositoryToken(ParentsChildren), + useValue: parentsChildrenRepo, + }, + ], + }).compile(); + service = module.get(EvenementsAgendaService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('parent crée absence_enfant → statut accepte', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + absencesRepo.findOne.mockResolvedValue({ + id: 'abs-1', + id_placement: 'pl-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + statut: StatutEvenementAgendaType.ACCEPTE, + expire_at: new Date('9999-12-31'), + cree_le: new Date(), + modifie_le: new Date(), + placement: { + enfantId: 'e-1', + amId: 'am-1', + child: { id: 'e-1', first_name: 'Léo' }, + am: { user: { prenom: 'Marie', nom: 'AM' } }, + }, + }); + + const res = await service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + }); + + expect(absencesRepo.save).toHaveBeenCalled(); + const savedArg = absencesRepo.save.mock.calls[0][0]; + expect(savedArg.statut).toBe(StatutEvenementAgendaType.ACCEPTE); + expect(res.type).toBe(TypeEvenementAgendaType.ABSENCE_ENFANT); + }); + + it('parent ne peut pas créer un congé AM', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeEvenementAgendaType.CONGE_AM, + date_debut: '2026-10-01', + date_fin: '2026-10-05', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('refus congé sans motif → BadRequest', async () => { + absencesRepo.findOne.mockResolvedValue({ + id: 'abs-1', + id_placement: 'pl-1', + type: TypeEvenementAgendaType.CONGE_AM, + date_debut: '2026-10-01', + date_fin: '2026-10-05', + statut: StatutEvenementAgendaType.EN_ATTENTE, + expire_at: new Date(), + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + + await expect( + service.maj('p-1', RoleType.PARENT, 'abs-1', { + statut: StatutEvenementAgendaType.REFUSE, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('dates invalides → BadRequest', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + date_debut: '2026-10-10', + date_fin: '2026-10-01', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.service.ts b/backend/src/modules/evenements-agenda/evenements-agenda.service.ts new file mode 100644 index 0000000..1c88723 --- /dev/null +++ b/backend/src/modules/evenements-agenda/evenements-agenda.service.ts @@ -0,0 +1,421 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { + EvenementsAgenda, + StatutEvenementAgendaType, + TypeEvenementAgendaType, +} from 'src/entities/evenements_agenda.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 { + EvenementAgendaDto, + CreerEvenementAgendaDto, + ListeEvenementsAgendaDto, + MajEvenementAgendaDto, +} from './dto/evenements-agenda.dto'; + +const TTL_EN_ATTENTE_MS = 15 * 24 * 60 * 60 * 1000; +const TTL_REFUSE_MS = 7 * 24 * 60 * 60 * 1000; +/** Sentinel « pas de purge » pour les périodes acceptées */ +const EXPIRE_ACCEPTE = new Date('9999-12-31T23:59:59.999Z'); + +@Injectable() +export class EvenementsAgendaService { + constructor( + @InjectRepository(EvenementsAgenda) + private readonly absencesRepo: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepo: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepo: Repository, + ) {} + + async lister( + userId: string, + role: RoleType, + opts: { + placementId?: string; + type?: TypeEvenementAgendaType; + statut?: StatutEvenementAgendaType; + from?: string; + to?: string; + }, + ): Promise { + const placementIds = await this.resolvePlacementIds( + userId, + role, + opts.placementId, + ); + if (placementIds.length === 0) { + return { items: [] }; + } + + const qb = this.absencesRepo + .createQueryBuilder('ag') + .leftJoinAndSelect('ag.placement', 'placement') + .leftJoinAndSelect('placement.child', 'child') + .leftJoinAndSelect('placement.am', 'am') + .leftJoinAndSelect('am.user', 'amUser') + .where('ag.id_placement IN (:...placementIds)', { placementIds }) + .orderBy('ag.date_debut', 'DESC'); + + if (opts.type) { + qb.andWhere('ag.type = :type', { type: opts.type }); + } + if (opts.statut) { + qb.andWhere('ag.statut = :statut', { statut: opts.statut }); + } + if (opts.from) { + qb.andWhere('ag.date_fin >= :from', { from: opts.from }); + } + if (opts.to) { + qb.andWhere('ag.date_debut <= :to', { to: opts.to }); + } + + const rows = await qb.getMany(); + return { items: rows.map((r) => this.toDto(r)) }; + } + + async creer( + userId: string, + role: RoleType, + dto: CreerEvenementAgendaDto, + ): Promise { + this.assertDates(dto.date_debut, dto.date_fin); + await this.assertCanAccessPlacement(userId, role, dto.id_placement); + this.assertCanCreateType(role, dto.type); + + const statut = this.statutInitial(dto.type); + const entity = this.absencesRepo.create({ + id_placement: dto.id_placement, + type: dto.type, + date_debut: dto.date_debut, + date_fin: dto.date_fin, + statut, + expire_at: this.expireAtFor(statut), + cree_par: userId, + motif: dto.motif?.trim() || undefined, + }); + const saved = await this.absencesRepo.save(entity); + return this.getByIdForUser(saved.id, userId, role); + } + + async maj( + userId: string, + role: RoleType, + id: string, + dto: MajEvenementAgendaDto, + ): Promise { + const row = await this.absencesRepo.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + + if (dto.date_debut !== undefined || dto.date_fin !== undefined) { + const debut = dto.date_debut ?? row.date_debut; + const fin = dto.date_fin ?? row.date_fin; + this.assertDates(debut, fin); + // Parent : peut modifier ses absences enfant + // AM : peut modifier congé/arrêt en_attente (avant accept) ou dates si créateur + this.assertCanEditDates(role, row); + row.date_debut = debut; + row.date_fin = fin; + if (row.statut === StatutEvenementAgendaType.EN_ATTENTE) { + row.expire_at = this.expireAtFor(StatutEvenementAgendaType.EN_ATTENTE); + } + } + + if (dto.statut !== undefined && dto.statut !== row.statut) { + this.assertCanChangeStatut(role, row, dto.statut, dto.motif); + if ( + dto.statut === StatutEvenementAgendaType.REFUSE && + (!dto.motif || !dto.motif.trim()) + ) { + throw new BadRequestException( + 'Une motivation est obligatoire en cas de refus', + ); + } + row.statut = dto.statut; + row.expire_at = this.expireAtFor(dto.statut); + if (dto.motif?.trim()) { + row.motif = dto.motif.trim(); + } + } else if (dto.motif !== undefined) { + row.motif = dto.motif.trim() || undefined; + } + + await this.absencesRepo.save(row); + return this.getByIdForUser(id, userId, role); + } + + async supprimer( + userId: string, + role: RoleType, + id: string, + ): Promise { + const row = await this.absencesRepo.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + + if ( + role === RoleType.PARENT && + row.type !== TypeEvenementAgendaType.ABSENCE_ENFANT + ) { + throw new ForbiddenException( + 'Un parent ne peut supprimer que les absences enfant', + ); + } + + await this.absencesRepo.delete({ id }); + } + + private async getByIdForUser( + id: string, + userId: string, + role: RoleType, + ): Promise { + const row = await this.absencesRepo.findOne({ + where: { id }, + relations: ['placement', 'placement.child', 'placement.am', 'placement.am.user'], + }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + return this.toDto(row); + } + + private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType { + if (type === TypeEvenementAgendaType.ABSENCE_ENFANT) { + return StatutEvenementAgendaType.ACCEPTE; + } + return StatutEvenementAgendaType.EN_ATTENTE; + } + + private expireAtFor(statut: StatutEvenementAgendaType): Date { + const now = Date.now(); + if (statut === StatutEvenementAgendaType.ACCEPTE) { + return EXPIRE_ACCEPTE; + } + if (statut === StatutEvenementAgendaType.REFUSE) { + return new Date(now + TTL_REFUSE_MS); + } + return new Date(now + TTL_EN_ATTENTE_MS); + } + + private assertDates(debut: string, fin: string): void { + if (fin < debut) { + throw new BadRequestException( + 'date_fin doit être supérieure ou égale à date_debut', + ); + } + } + + private assertCanCreateType(role: RoleType, type: TypeEvenementAgendaType): void { + if (role === RoleType.PARENT) { + if (type !== TypeEvenementAgendaType.ABSENCE_ENFANT) { + throw new ForbiddenException( + 'Un parent ne peut créer que des absences enfant', + ); + } + return; + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if ( + type !== TypeEvenementAgendaType.CONGE_AM && + type !== TypeEvenementAgendaType.ARRET_MALADIE_AM + ) { + throw new ForbiddenException( + 'Une AM ne peut créer que congé ou arrêt maladie', + ); + } + return; + } + throw new ForbiddenException('Rôle non autorisé à créer une absence'); + } + + private assertCanEditDates( + role: RoleType, + row: EvenementsAgenda, + ): void { + if (role === RoleType.PARENT) { + if (row.type !== TypeEvenementAgendaType.ABSENCE_ENFANT) { + throw new ForbiddenException( + 'Un parent ne peut modifier que les absences enfant', + ); + } + return; + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if ( + row.type === TypeEvenementAgendaType.CONGE_AM || + row.type === TypeEvenementAgendaType.ARRET_MALADIE_AM + ) { + if ( + row.statut !== StatutEvenementAgendaType.EN_ATTENTE && + row.statut !== StatutEvenementAgendaType.REFUSE && + row.statut !== StatutEvenementAgendaType.ACCEPTE + ) { + throw new ForbiddenException('Statut incompatible avec une modification'); + } + // Accepté : autorisé (S2b — re-validation via cartes plus tard ; API permet update dates) + return; + } + throw new ForbiddenException('Type non modifiable par l’AM'); + } + throw new ForbiddenException('Modification non autorisée'); + } + + private assertCanChangeStatut( + role: RoleType, + row: EvenementsAgenda, + next: StatutEvenementAgendaType, + _motif?: string, + ): void { + if (role === RoleType.PARENT) { + // Accept / refuse congé ; ack arrêt (accepte) + if ( + row.type === TypeEvenementAgendaType.CONGE_AM || + row.type === TypeEvenementAgendaType.ARRET_MALADIE_AM + ) { + if ( + next !== StatutEvenementAgendaType.ACCEPTE && + next !== StatutEvenementAgendaType.REFUSE + ) { + throw new BadRequestException('Transition de statut invalide'); + } + if ( + row.type === TypeEvenementAgendaType.ARRET_MALADIE_AM && + next === StatutEvenementAgendaType.REFUSE + ) { + throw new BadRequestException( + 'Un arrêt maladie ne se refuse pas (accusé seulement)', + ); + } + if (row.statut !== StatutEvenementAgendaType.EN_ATTENTE) { + throw new BadRequestException('Cette demande n’est plus en attente'); + } + return; + } + throw new ForbiddenException( + 'Pas de changement de statut sur ce type pour un parent', + ); + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + // Remise en attente après refus OU modification d’un congé déjà accepté (S2b) + if ( + next === StatutEvenementAgendaType.EN_ATTENTE && + (row.statut === StatutEvenementAgendaType.REFUSE || + row.statut === StatutEvenementAgendaType.ACCEPTE) + ) { + return; + } + throw new ForbiddenException( + 'L’AM ne valide pas elle-même (sauf republication / modification)', + ); + } + throw new ForbiddenException('Changement de statut non autorisé'); + } + + private async resolvePlacementIds( + userId: string, + role: RoleType, + placementId?: string, + ): Promise { + if (placementId) { + await this.assertCanAccessPlacement(userId, role, placementId); + return [placementId]; + } + + if (role === RoleType.PARENT) { + const liens = await this.parentsChildrenRepo.find({ + where: { parentId: userId }, + select: ['enfantId'], + }); + const enfantIds = liens.map((l) => l.enfantId); + if (enfantIds.length === 0) return []; + const placements = await this.amChildrenRepo.find({ + where: { enfantId: In(enfantIds), date_fin: IsNull() }, + select: ['id'], + }); + return placements.map((p) => p.id); + } + + if (role === RoleType.ASSISTANTE_MATERNELLE) { + const placements = await this.amChildrenRepo.find({ + where: { amId: userId, date_fin: IsNull() }, + select: ['id'], + }); + return placements.map((p) => p.id); + } + + throw new ForbiddenException('Rôle non autorisé'); + } + + private async assertCanAccessPlacement( + 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 / couple introuvable ou inactif'); + } + + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if (placement.amId !== userId) { + throw new ForbiddenException('Ce placement ne vous appartient pas'); + } + return placement; + } + + if (role === RoleType.PARENT) { + const lien = await this.parentsChildrenRepo.findOne({ + where: { parentId: userId, enfantId: placement.enfantId }, + }); + if (!lien) { + throw new ForbiddenException( + 'Ce placement ne concerne pas un de vos enfants', + ); + } + return placement; + } + + throw new ForbiddenException('Rôle non autorisé'); + } + + private toDto(row: EvenementsAgenda): EvenementAgendaDto { + const child = row.placement?.child; + const amUser = row.placement?.am?.user; + return { + id: row.id, + id_placement: row.id_placement, + type: row.type, + date_debut: row.date_debut, + date_fin: row.date_fin, + statut: row.statut, + expire_at: row.expire_at?.toISOString?.() ?? String(row.expire_at), + cree_par: row.cree_par ?? null, + motif: row.motif ?? null, + id_enfant: child?.id ?? row.placement?.enfantId ?? null, + prenom_enfant: child?.first_name ?? null, + id_am: row.placement?.amId ?? null, + prenom_am: amUser?.prenom ?? null, + nom_am: amUser?.nom ?? null, + cree_le: row.cree_le?.toISOString?.() ?? String(row.cree_le), + modifie_le: row.modifie_le?.toISOString?.() ?? String(row.modifie_le), + }; + } +} diff --git a/backend/src/modules/evenements-agenda/index.ts b/backend/src/modules/evenements-agenda/index.ts new file mode 100644 index 0000000..566e0f2 --- /dev/null +++ b/backend/src/modules/evenements-agenda/index.ts @@ -0,0 +1,2 @@ +export { EvenementsAgendaModule } from './evenements-agenda.module'; +export { EvenementsAgendaService } from './evenements-agenda.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..cb5cea0 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -35,13 +35,13 @@ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') THEN - CREATE TYPE type_absence_garde_type AS ENUM ( + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN + CREATE TYPE type_evenement_agenda_type AS ENUM ( 'absence_enfant', 'conge_am', 'arret_maladie_am' ); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') THEN - CREATE TYPE statut_absence_garde_type AS ENUM ( + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN + CREATE TYPE statut_evenement_agenda_type AS ENUM ( 'en_attente', 'accepte', 'refuse' ); END IF; @@ -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) -- ========================================================== @@ -309,32 +322,32 @@ CREATE TABLE avenants_contrats ( ); -- ========================================================== --- Table : absences_garde (vérité métier absences/congés/arrêt) +-- Table : evenements_agenda (vérité métier agenda (absences/congés/arrêt + futurs types)) -- 1 ligne = 1 période ; rattachement = placement AM↔enfant (#193) -- ========================================================== -CREATE TABLE absences_garde ( +CREATE TABLE evenements_agenda ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_placement UUID NOT NULL REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE, - type type_absence_garde_type NOT NULL, + type type_evenement_agenda_type NOT NULL, date_debut DATE NOT NULL, date_fin DATE NOT NULL, - statut statut_absence_garde_type NOT NULL DEFAULT 'en_attente', + statut statut_evenement_agenda_type NOT NULL DEFAULT 'en_attente', expire_at TIMESTAMPTZ NOT NULL, cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, id_card_instance UUID, motif TEXT, cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), modifie_le TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_absences_garde_dates CHECK (date_fin >= date_debut) + CONSTRAINT chk_evenements_agenda_dates CHECK (date_fin >= date_debut) ); -CREATE INDEX idx_absences_garde_placement_dates - ON absences_garde (id_placement, date_debut, date_fin); -CREATE INDEX idx_absences_garde_placement_type_statut - ON absences_garde (id_placement, type, statut); -CREATE INDEX idx_absences_garde_expire_at - ON absences_garde (expire_at) +CREATE INDEX idx_evenements_agenda_placement_dates + ON evenements_agenda (id_placement, date_debut, date_fin); +CREATE INDEX idx_evenements_agenda_placement_type_statut + ON evenements_agenda (id_placement, type, statut); +CREATE INDEX idx_evenements_agenda_expire_at + ON evenements_agenda (expire_at) WHERE statut IN ('en_attente', 'refuse'); -- ========================================================== diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index 71162cb..b93cdf1 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -106,7 +106,7 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base ## 7) Type d’absence de garde — `type` -**Tables/colonnes** : `absences_garde.type` +**Tables/colonnes** : `evenements_agenda.type` **Valeurs autorisées** : | Valeur | Description | @@ -115,13 +115,13 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base | `conge_am` | Congé de l’assistante maternelle | | `arret_maladie_am` | Arrêt maladie AM | -> Remplace l’ancien enum `type_evenement_type` / table `evenements` (supprimés ticket #193). +> Remplace l’ancien enum `type_evenement_type` / table `evenements` (supprimés ticket #205 / ex-#193). --- ## 8) Statut d’absence de garde — `statut` -**Tables/colonnes** : `absences_garde.statut` +**Tables/colonnes** : `evenements_agenda.statut` **Valeurs autorisées** : | Valeur | Description | diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index 9714b35..84e7f36 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -42,8 +42,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **contrats(id_dossier)** → `dossiers(id)` | **CASCADE** | 1:1, contrat détruit si dossier supprimé | | **avenants_contrats(id_contrat)** → `contrats(id)` | **CASCADE** | Avenants détruits avec le contrat | | **avenants_contrats(initie_par)** → `utilisateurs(id)` | **SET NULL** | Historiser l’avenant sans bloquer | -| **absences_garde(id_placement)** → `enfants_assistantes_maternelles(id)` | **CASCADE** | Plus de couple = plus d’absences | -| **absences_garde(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver la période si user supprimé | +| **evenements_agenda(id_placement)** → `enfants_assistantes_maternelles(id)` | **CASCADE** | Plus de couple = plus d’absences | +| **evenements_agenda(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver la période si user supprimé | | **signalements_bugs(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Conserver le ticket même si compte supprimé | | **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur | | **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur | @@ -77,8 +77,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo - `avenants_contrats.initie_par` → **SET NULL** ### Événements -- `absences_garde.id_placement` → **CASCADE** -- `absences_garde.cree_par` → **SET NULL** +- `evenements_agenda.id_placement` → **CASCADE** +- `evenements_agenda.cree_par` → **SET NULL** ### Divers - `signalements_bugs.id_utilisateur` → **SET NULL** @@ -105,7 +105,7 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo 4. **Suppression d’un enfant** - Supprimer `enfants(id=childA)` - - Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `absences_garde` des placements du childA (CASCADE via EAM). + - Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `evenements_agenda` des placements du childA (CASCADE via EAM). 5. **Suppression d’un utilisateur AM** - Supprimer `utilisateurs(id=amB)` 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/database/migrations/2026_evenements_agenda_rename.sql b/database/migrations/2026_evenements_agenda_rename.sql new file mode 100644 index 0000000..4554fbe --- /dev/null +++ b/database/migrations/2026_evenements_agenda_rename.sql @@ -0,0 +1,106 @@ +-- Ticket #205 — Renommer absences_garde → evenements_agenda +-- Migration base live. Idempotent autant que possible. + +-- --------------------------------------------------------------------------- +-- 1. Table + enums +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'absences_garde') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'evenements_agenda') THEN + ALTER TABLE absences_garde RENAME TO evenements_agenda; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') + AND NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN + ALTER TYPE type_absence_garde_type RENAME TO type_evenement_agenda_type; + END IF; + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') + AND NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN + ALTER TYPE statut_absence_garde_type RENAME TO statut_evenement_agenda_type; + END IF; +END $$; + +-- Contraintes / index (noms) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_absences_garde_dates') THEN + ALTER TABLE evenements_agenda RENAME CONSTRAINT chk_absences_garde_dates TO chk_evenements_agenda_dates; + END IF; +END $$; + +ALTER INDEX IF EXISTS idx_absences_garde_placement_dates + RENAME TO idx_evenements_agenda_placement_dates; +ALTER INDEX IF EXISTS idx_absences_garde_placement_type_statut + RENAME TO idx_evenements_agenda_placement_type_statut; +ALTER INDEX IF EXISTS idx_absences_garde_expire_at + RENAME TO idx_evenements_agenda_expire_at; +ALTER INDEX IF EXISTS absences_garde_pkey + RENAME TO evenements_agenda_pkey; + +COMMENT ON TABLE evenements_agenda IS + 'Vérité métier agenda (absences, congés, arrêts, futurs soins/école/mairie/sorties…) par placement. Les cartes collectent ; cette table historise.'; +COMMENT ON COLUMN evenements_agenda.expire_at IS + 'Purge auto (job) pour en_attente/refuse. accepte → infinity.'; +COMMENT ON COLUMN evenements_agenda.id_card_instance IS + 'Réf. carte de collecte (module Cartes).'; + +-- --------------------------------------------------------------------------- +-- 2. card_instances.id_absence → id_evenement +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'card_instances' AND column_name = 'id_absence' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'card_instances' AND column_name = 'id_evenement' + ) THEN + ALTER TABLE card_instances RENAME COLUMN id_absence TO id_evenement; + END IF; +END $$; + +-- Recréer index si ancien nom +ALTER INDEX IF EXISTS idx_card_instances_absence + RENAME TO idx_card_instances_evenement; + +-- FK : drop ancienne (nom variable) puis recreate +DO $$ +DECLARE + fk_name text; +BEGIN + SELECT conname INTO fk_name + FROM pg_constraint + WHERE conrelid = 'card_instances'::regclass + AND contype = 'f' + AND pg_get_constraintdef(oid) ILIKE '%id_evenement%' + LIMIT 1; + + IF fk_name IS NULL THEN + SELECT conname INTO fk_name + FROM pg_constraint + WHERE conrelid = 'card_instances'::regclass + AND contype = 'f' + AND pg_get_constraintdef(oid) ILIKE '%id_absence%' + LIMIT 1; + END IF; + + IF fk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE card_instances DROP CONSTRAINT %I', fk_name); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'card_instances_id_evenement_fkey' + ) THEN + ALTER TABLE card_instances + ADD CONSTRAINT card_instances_id_evenement_fkey + FOREIGN KEY (id_evenement) + REFERENCES evenements_agenda(id) + ON DELETE SET NULL; + END IF; +END $$; diff --git a/database/tests/sql/verify.sql b/database/tests/sql/verify.sql index 4104ab0..df39b34 100644 --- a/database/tests/sql/verify.sql +++ b/database/tests/sql/verify.sql @@ -81,7 +81,7 @@ ORDER BY c.cree_le DESC; \echo '=== 9) Absences_garde (30 derniers jours) =====================' SELECT ag.id, ag.type, ag.id_placement, ag.date_debut, ag.date_fin, ag.statut, ag.expire_at -FROM absences_garde ag +FROM evenements_agenda ag WHERE ag.date_debut >= (NOW()::date - INTERVAL '30 days') ORDER BY ag.date_debut DESC; @@ -136,7 +136,7 @@ WHERE c.id IS NULL; -- Absences sans placement SELECT ag.* -FROM absences_garde ag +FROM evenements_agenda ag LEFT JOIN enfants_assistantes_maternelles eam ON eam.id = ag.id_placement WHERE eam.id IS NULL; @@ -150,10 +150,10 @@ WHERE m.id_dossier = 'dddddddd-dddd-dddd-dddd-dddddddddddd' ORDER BY m.cree_le DESC LIMIT 20; --- Absences par placement et période (idx_absences_garde_placement_dates) +-- Absences par placement et période (idx_evenements_agenda_placement_dates) EXPLAIN ANALYZE SELECT ag.* -FROM absences_garde ag +FROM evenements_agenda ag WHERE ag.id_placement IS NOT NULL AND ag.date_debut >= '2025-01-01'; @@ -198,7 +198,7 @@ SELECT (SELECT COUNT(*) FROM messages) AS nb_messages, (SELECT COUNT(*) FROM contrats) AS nb_contrats, (SELECT COUNT(*) FROM avenants_contrats) AS nb_avenants, - (SELECT COUNT(*) FROM absences_garde) AS nb_absences_garde, + (SELECT COUNT(*) FROM evenements_agenda) AS nb_evenements_agenda, (SELECT COUNT(*) FROM uploads) AS nb_uploads, (SELECT COUNT(*) FROM notifications) AS nb_notifications, (SELECT COUNT(*) FROM validations) AS nb_validations; diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index 426643c..35397b8 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -572,14 +572,28 @@ POST /repos/jmartin/petitspas/pulls/{index}/merge ### 32. Module Absences + Cartes (séparation back / collecte) -**Décision** : ✅ **Back `absences_garde` = vérité métier** ; **Cartes = file d’attention / collecte** (plugin isolé). Types SYSTEM (absence, congé, arrêt) indéboulonnables ; types OPTIONNELS (sondages…) plus tard. Annulation = DELETE. `expire_at` dès V1. +**Décision** : ✅ **Back `evenements_agenda` = vérité métier / agenda** ; **Cartes = file d’attention** (plugin isolé). Types SYSTEM (absence, congé, arrêt) indéboulonnables ; types OPTIONNELS (sondages, soins enfant, école, mairie, sorties…) plus tard. Annulation = DELETE. `expire_at` dès V1. **Justification** : - Premier vrai module d’interaction multi-acteurs -- Évite de stocker l’historique congés dans des bulles éphémères -- Portabilité / généricité des cartes sans coupler le métier garde +- Évite de stocker l’historique dans des bulles éphémères +- Portabilité / généricité des cartes sans coupler le métier +- **Différence avec le blog** : les cartes du fil = **événements d’agenda** (période + placement + historique) ; le blog = récit narratif du jour -**Flux V1** : BDD absences → API liste/CRUD → module Cartes SYSTEM → front bulles (séparé). +**Modèle données** : +- Table **`evenements_agenda`** (ex-`absences_garde`, rename [#205](https://git.ptits-pas.fr/jmartin/petitspas/issues/205)) — nom volontairement ouvert + +**UI** : la **couleur** des bulles est un mapping **front** (`type_code` → teinte). L’API n’expose pas `couleur`. + +**Flux V1** : BDD agenda → API liste/CRUD → module Cartes SYSTEM → front bulles (séparé). + +**V1 cartes (réponses)** : +- `absence_enfant` → info (`none`) — pas d’ack AM ; audience **AM + tous les parents du foyer** (co-parent inclus) +- `arret_maladie_am` → ack parent (« Bien reçu ») +- `conge_am` → accept / refuse (+ motivation refus) +- **Modifs d’absence** → via **agenda / calendrier**, pas via bulle `absence_enfant_modif` (reporté au ticket calendrier) + +**Réf. détail** : `docs/32_MINI-SPEC-BULLES-CARTES.md` --- @@ -615,7 +629,7 @@ POST /repos/jmartin/petitspas/pulls/{index}/merge | 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales | | 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde | | 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | -| 24/09/2026 | 1.4 | Décision 32 — Absences (`absences_garde`) ≠ Cartes ; Epic C recentré ; drop `evenements` | +| 24/09/2026 | 1.4 | Décision 32 — Absences (`evenements_agenda`) ≠ Cartes ; Epic C recentré ; drop `evenements` | --- diff --git a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md index d519a14..939bc47 100644 --- a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md +++ b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md @@ -98,16 +98,16 @@ Liste des enfants / foyers pour l’AM + contexte courant (symétrique A3). ## Epic C — Absences + Cartes (file d’attention) -**Séparation :** back **`absences_garde`** = vérité métier (périodes / placement) ; module **Cartes** = collecte / bulles / workflow. Annulation = DELETE. `expire_at` dès le modèle. +**Séparation :** back **`evenements_agenda`** = vérité métier (périodes / placement) ; module **Cartes** = collecte / bulles / workflow. Annulation = DELETE. `expire_at` dès le modèle. ### Back (ordre) | Étape | Contenu | |-------|---------| -| BDD | Table `absences_garde` + drop `evenements` | +| BDD | Table `evenements_agenda` + 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/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md b/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md index 05d0d8a..4bc19d0 100644 --- a/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md +++ b/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md @@ -72,7 +72,7 @@ Bandeau : **TdB** · **Agenda** · **Contrat** · menu user (recherche AM, param | Maladie AM | AM | Parent **ack** (« bien reçu ») ; **aucun** doc médical | | Sortie / sondage | — | **Plus tard** (types optionnels) | -**Stockage :** table `absences_garde` (1 ligne = période, `id_placement`, `expire_at`). Les **cartes** collectent ; elles ne sont pas la source de vérité. Annulation = DELETE. +**Stockage :** table `evenements_agenda` (1 ligne = période, `id_placement`, `expire_at`). Les **cartes** collectent ; elles ne sont pas la source de vérité. Annulation = DELETE. **Péremption cartes :** mémoire courte (`retention_days` / `purge_at`) — pas un historique de vie (≠ messagerie). @@ -103,7 +103,7 @@ Avec données peuplées, sur au moins 2 supports : - Parent : `frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart` + `dashbord_parent/` - AM : `frontend/lib/screens/am/am_dashboard_screen.dart` (placeholder) -- Cartes couleurs : `frontend/assets/cards/` (7 teintes max) +- Cartes couleurs : `frontend/assets/cards/` (8 teintes max) ## 9. Ordre de build suggéré diff --git a/docs/32_MINI-SPEC-BULLES-CARTES.md b/docs/32_MINI-SPEC-BULLES-CARTES.md new file mode 100644 index 0000000..d7f9583 --- /dev/null +++ b/docs/32_MINI-SPEC-BULLES-CARTES.md @@ -0,0 +1,195 @@ +# Mini-spec · Bulles / file d’attention (#173) + +*Front · octobre 2026 — alignement product / API.* + +--- + +## 1. Contexte / objectif + +Les bulles (cartes) sont la **file d’attention** du quotidien parent / AM : décisions et infos proches (congés, arrêt maladie AM, absences, soins / bobos enfant…). + +**Différence fondamentale avec le blog :** +les cartes de ce fil sont des **événements d’agenda** (période, placement, historique métier loggé). +Le **blog** = récit / mémoire narrative du jour (photos, texte) — pas une ligne d’agenda. + +Les cartes restent une **mémoire courte** de file (`purge_at` sur la bulle) ; la **source de vérité agenda** reste le back métier. + +Table agenda : **`evenements_agenda`** (ex-`absences_garde`, [#205](https://git.ptits-pas.fr/jmartin/petitspas/issues/205) fait) — conteneur ouvert (absences, congés, soins, école, mairie, sorties…). Les bulles notifient ; l’agenda historise. + +Voir aussi `31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`. + +Cette note formalise : +1. Ce qui est en place côté front (#173). +2. Le contrat API (sans couleur). +3. La **palette UI** (front only, dérivée du `type_code`). +4. La nouvelle famille **soins / bobo enfant** (rose) — AM → parents. +5. Idées futures. + +--- + +## 2. Contrat API + +Endpoint principal : `GET /api/v1/cards` (filtre couple / placement). + +| Champ | Rôle UI | +|-------|---------| +| `type_code` | Layout + libellé + **couleur / icône (mapping front)** | +| `titre` | Fallback si `type_code` inconnu | +| `statut` | `ouverte` · `refusee` · `traitee` | +| `response_mode` | `none` · `ack` · `accept_refuse` | +| `is_creator` | Masque les boutons réponse pour le créateur | +| `payload.date_debut` / `date_fin` | Période (agenda) | +| `payload.motif` | Texte libre / détail | +| `last_refuse_comment` | Si `refusee` | +| `id_evenement` | Lien métier agenda (`evenements_agenda`, si applicable) — ex-`id_absence` (#205) | +| `purge_at` | Péremption de la bulle dans la file | + +> **Front** : parser `id_evenement` (fallback éventuel `id_absence` uniquement pour anciennes fixtures / caches). Ne plus documenter ni dépendre de `id_absence` comme contrat. + +**Pas de champ `couleur` dans l’API.** +La teinte pastel est **100 % front** : table de mapping `type_code` → asset (`card_*_h.png`). Le back ne gère pas la charte graphique. + +**Règle front :** le destinataire peut répondre si `!is_creator && statut == ouverte && response_mode != none`. + +--- + +## 3. Évolutions front déjà livrées (#173) + +### 3.1 Feed + +- Widget partagé `CartesFeed` (parent + AM). +- Fond via widget `Carte` (9-slice horizontal) selon mapping local. +- Actions via **modales** accepter / refuser / ack. +- Libellés UI : + - `conge_am` → « Demande de congés » + - `arret_maladie_am` → « Arrêt maladie » + - `absence_enfant` → « Notification d’absence » + +### 3.2 Carte **congés** (`conge_am`) + +| Élément | Comportement | +|---------|----------------| +| Fond | **Aqua** (mapping front) | +| Encre | Pétrole `#315F63` / dates `#4A7276` | +| Icône | Valise | +| Période | « Du … au … » + calendrier | +| Actions | Pilules ✓ / ✕ si `accept_refuse` | +| Lien | « Ouvrir dans l’agenda ↗ » | +| Opacité | Atténuée si `traitee` (sauf règle arrêt ci-dessous) | + +### 3.3 Arrêt maladie AM (`arret_maladie_am`) + +- Reste **visible / opaque** tant que `date_fin` (jour inclus) n’est pas passée, même si `traitee`. +- Fond **bleu** (mapping front). + +### 3.4 Assets + +Teintes UI : `red` · `pink` · `peach` · `lime` · `lavender` · `green` · `blue` · `aqua`. +Clé = **choix front**, pas une valeur API. + +--- + +## 4. Palette UI (front only — dérivée du `type_code`) + +| Couleur UI | Famille | Types | +|------------|---------|--------| +| **`aqua`** | Congés AM | `conge_am` | +| **`blue`** | Arrêt maladie AM | `arret_maladie_am` | +| **`peach`** | Absence enfant / info pratique | `absence_enfant` | +| **`pink`** | Soins / bobo / problème **enfant** (AM → parents) | `soin_enfant` *(nouveau — voir §5)* | +| **`lavender`** | Admin / divers | futurs types admin | + +**Hors scope V1 cartes** : `absence_enfant_modif` (ack AM sur modif). +Les **modifications d’absence** se font via l’**agenda / calendrier**, pas via une bulle dédiée. On rebranchera une notif éventuelle quand le calendrier sera implémenté. + +**Principes** + +- Couleur = famille visuelle (lisible sans tout lire). +- Icône précise l’événement (surtout sur rose : thermomètre, pansement, flacon…). +- Rose = doux / rassurant, **pas** une alerte médicale forte. +- Congés ≠ lavande (trop « admin »). + +--- + +## 5. Nouvelle bulle rose — soins / bobo enfant (AM → parents) + +### Intention produit + +La nounou signale un **événement concernant l’enfant** (maladie légère, bobo, crème, température, souci du jour…) **vers les parents**. + +Pourquoi **pas seulement la messagerie** ? +- C’est **loggé** dans la file d’attention. +- C’est un **événement d’agenda** (période / jour, placement, historique). +- Les parents peuvent **acquitter** (`ack`) sans noyer le chat. + +### Proposition V1 (à brancher ultérieurement) + +| | | +|--|--| +| `type_code` | `soin_enfant` (nom exact à figer) | +| Émetteur | AM | +| Destinataires | parents du couple / placement | +| `response_mode` | `ack` | +| Couleur UI | `pink` | +| Back métier | ligne agenda liée (même esprit que `absences_garde` : période + placement + motif) ; la bulle = collecte / notification | +| Payload | `date_debut` / `date_fin` (+ motif / sous-type optionnel : temperature, bobo, medicament…) | + +**Hors scope immédiat #173** : implémentation complète ; cette section fige l’intention pour le contrat types + UI. + +--- + +## 6. Idées futures (hors scope) + +| Famille | Couleur UI | Exemples | `response_mode` | +|---------|------------|----------|-----------------| +| Soins (détail) | `pink` | `soin_temperature`, `soin_medicament`, `soin_bobo` — ou sous-types dans payload | `ack` | +| Modif absence | `peach` | `absence_enfant_modif` — **via calendrier** d’abord ; bulle ack AM éventuelle ensuite | `ack` | +| Sortie | `peach` / `lavender` | `sortie_a_valider` | `accept_refuse` | +| Admin | `lavender` | `info_admin`, `document_a_fournir` | `ack` / `none` | + +Variante soins : un seul fond rose ; l’icône / sous-type change. + +--- + +## 7. Comportements métier à ne pas casser + +- Créateur : pas de boutons réponse ; éventuel « En attente de réponse ». +- Refus : afficher `last_refuse_comment`. +- Congé : `accept_refuse` côté destinataire. +- Absence enfant : **info seule** (`response_mode: none`) — pas de veto ni ack AM en V1. +- Absence enfant : audience = **AM + tous les parents du foyer** (créateur + co-parent), pas seulement le déclarant. +- Modif / édition d’absence : **agenda**, pas les cartes (V1). +- File courte : `purge_at` ; l’**agenda** conserve l’historique métier. + +--- + +## 8. Fichiers front de référence + +- `frontend/lib/models/carte_bulle.dart` — mapping type → libellé / icône / fond +- `frontend/lib/widgets/quotidien/cartes_feed.dart` +- `frontend/lib/models/card_assets.dart` +- Assets : `frontend/assets/cards/`, `valise.png`, `btn_*_pilule.png` + +--- + +## 9. Recettes + +- [ ] API cartes **sans** champ `couleur` (ni feed ni `/types`). +- [ ] Contrat lien agenda : champ API `id_evenement` (plus `id_absence`). +- [ ] Front mappe `conge_am` → aqua, `arret_maladie_am` → blue, `absence_enfant` → peach. +- [ ] Accept / refuse congé OK. +- [ ] Arrêt : ack parent « Bien reçu » ; traité + période en cours → reste visible. +- [ ] Absence enfant : pas de bouton (info). +- [ ] Filtre couple / placement. +- [ ] (Plus tard) `soin_enfant` rose AM → parents + ligne agenda. +- [ ] (Plus tard / calendrier) modifs d’absence + éventuel `absence_enfant_modif`. + +--- + +## 10. Références + +- Ticket front : **#173** +- Module cartes : **#194** +- Quotidien : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md` +- Décision projet : `docs/24_DECISIONS-PROJET.md` §32 +- Maquette congés : `ressources/bulle_congés.jpg` 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/models/card_assets.dart b/frontend/lib/models/card_assets.dart index c86b663..926d696 100644 --- a/frontend/lib/models/card_assets.dart +++ b/frontend/lib/models/card_assets.dart @@ -5,7 +5,8 @@ enum CardColorVertical { lime('assets/cards/card_lime.png'), lavender('assets/cards/card_lavender.png'), green('assets/cards/card_green.png'), - blue('assets/cards/card_blue.png'); + blue('assets/cards/card_blue.png'), + aqua('assets/cards/card_aqua.png'); final String path; const CardColorVertical(this.path); @@ -18,8 +19,9 @@ enum CardColorHorizontal { lime('assets/cards/card_lime_h.png'), lavender('assets/cards/card_lavender_h.png'), green('assets/cards/card_green_h.png'), - blue('assets/cards/card_blue_h.png'); + blue('assets/cards/card_blue_h.png'), + aqua('assets/cards/card_aqua_h.png'); final String path; const CardColorHorizontal(this.path); -} \ No newline at end of file +} diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart new file mode 100644 index 0000000..fbc3b94 --- /dev/null +++ b/frontend/lib/models/carte_bulle.dart @@ -0,0 +1,205 @@ +import 'package:p_tits_pas/models/card_assets.dart'; + +/// Statut d'une instance de carte (file d'attention) — contrat #194 / #173. +enum CarteStatut { + ouverte('ouverte'), + refusee('refusee'), + traitee('traitee'); + + final String apiValue; + const CarteStatut(this.apiValue); + + static CarteStatut fromApi(String? raw) { + switch (raw) { + case 'refusee': + return CarteStatut.refusee; + case 'traitee': + return CarteStatut.traitee; + case 'ouverte': + default: + return CarteStatut.ouverte; + } + } +} + +/// Mode de réponse attendu selon le type SYSTEM. +enum CarteResponseMode { + none('none'), + ack('ack'), + acceptRefuse('accept_refuse'); + + final String apiValue; + const CarteResponseMode(this.apiValue); + + static CarteResponseMode fromApi(String? raw) { + switch (raw) { + case 'ack': + return CarteResponseMode.ack; + case 'accept_refuse': + return CarteResponseMode.acceptRefuse; + case 'none': + default: + return CarteResponseMode.none; + } + } +} + +/// Bulle / carte de la file d'attention. +class CarteBulle { + final String id; + final String typeCode; + final String titre; + final String idPlacement; + final String? idAbsence; + final String operation; + final CarteStatut statut; + final Map payload; + final DateTime? purgeAt; + final String? creePar; + final bool isCreator; + final CarteResponseMode responseMode; + final String? lastRefuseComment; + final DateTime? creeLe; + final DateTime? modifieLe; + + const CarteBulle({ + required this.id, + required this.typeCode, + required this.titre, + required this.idPlacement, + this.idAbsence, + this.operation = 'create', + required this.statut, + this.payload = const {}, + this.purgeAt, + this.creePar, + this.isCreator = false, + this.responseMode = CarteResponseMode.none, + this.lastRefuseComment, + this.creeLe, + this.modifieLe, + }); + + String? get dateDebut => payload['date_debut']?.toString(); + String? get dateFin => payload['date_fin']?.toString(); + String? get motif => payload['motif']?.toString(); + + /// Motif de refus : réponse API, sinon motif payload (co-parent / reload). + String? get motifRefus { + final fromResponse = lastRefuseComment?.trim(); + if (fromResponse != null && fromResponse.isNotEmpty) return fromResponse; + final fromPayload = motif?.trim(); + if (fromPayload != null && fromPayload.isNotEmpty) return fromPayload; + return null; + } + + /// Libellé UI (sémantique produit) — pas le titre brut API. + String get titreAffiche { + switch (typeCode) { + case 'conge_am': + return 'Demande de congés'; + case 'arret_maladie_am': + return 'Arrêt maladie'; + case 'absence_enfant': + case 'absence_enfant_modif': + return 'Notification d’absence'; + case 'soin_enfant': + return 'Soin / bobo'; + default: + return titre; + } + } + + bool get isArretMaladie => typeCode == 'arret_maladie_am'; + bool get isCongeAm => typeCode == 'conge_am'; + bool get isSoinEnfant => typeCode == 'soin_enfant'; + + /// Icône dessin (haut droite) selon le type de bulle. + String? get iconAsset { + switch (typeCode) { + case 'conge_am': + return 'assets/images/valise.png'; + case 'arret_maladie_am': + return 'assets/images/thermo_croix.png'; + case 'absence_enfant': + case 'absence_enfant_modif': + return 'assets/images/sac_lapin.png'; + // soin_enfant : icône selon sous-type payload — à brancher plus tard + default: + return null; + } + } + + /// Fin de période (jour inclus). Null si dates absentes / invalides. + DateTime? get dateFinJour { + final raw = dateFin; + if (raw == null || raw.isEmpty) return null; + final dt = DateTime.tryParse(raw); + if (dt == null) return null; + return DateTime(dt.year, dt.month, dt.day); + } + + /// Vrai tant que la date de fin n’est pas dépassée (jour courant inclus). + bool get periodeEncoreActive { + final fin = dateFinJour; + if (fin == null) return true; + final today = DateTime.now(); + final todayDay = DateTime(today.year, today.month, today.day); + return !todayDay.isAfter(fin); + } + + /// Teinte pastel UI dérivée du `type_code` (pas de `couleur` API). + /// Voir `docs/32_MINI-SPEC-BULLES-CARTES.md` §4. + CardColorHorizontal get colorFamily { + switch (typeCode) { + case 'conge_am': + return CardColorHorizontal.aqua; + case 'arret_maladie_am': + return CardColorHorizontal.blue; + case 'absence_enfant': + case 'absence_enfant_modif': + return CardColorHorizontal.peach; + case 'soin_enfant': + return CardColorHorizontal.pink; + default: + return CardColorHorizontal.lavender; + } + } + + /// Asset horizontal pastel (palette `assets/cards/` uniquement). + String get backgroundAsset => colorFamily.path; + + /// Le destinataire peut répondre (pas le créateur, carte ouverte). + bool get peutRepondre => + !isCreator && + statut == CarteStatut.ouverte && + responseMode != CarteResponseMode.none; + + factory CarteBulle.fromJson(Map json) { + final rawPayload = json['payload']; + return CarteBulle( + id: (json['id'] ?? '').toString(), + typeCode: (json['type_code'] ?? '').toString(), + titre: (json['titre'] ?? json['type_code'] ?? 'Carte').toString(), + idPlacement: (json['id_placement'] ?? '').toString(), + idAbsence: json['id_absence']?.toString(), + operation: (json['operation'] ?? 'create').toString(), + statut: CarteStatut.fromApi(json['statut']?.toString()), + payload: rawPayload is Map + ? Map.from(rawPayload) + : const {}, + purgeAt: _parseDate(json['purge_at']), + creePar: json['cree_par']?.toString(), + isCreator: json['is_creator'] == true, + responseMode: CarteResponseMode.fromApi(json['response_mode']?.toString()), + lastRefuseComment: json['last_refuse_comment']?.toString(), + creeLe: _parseDate(json['cree_le']), + modifieLe: _parseDate(json['modifie_le']), + ); + } + + static DateTime? _parseDate(dynamic v) { + if (v == null) return null; + return DateTime.tryParse(v.toString()); + } +} diff --git a/frontend/lib/models/couple_garde.dart b/frontend/lib/models/couple_garde.dart index 1d1f1ab..0ec4f60 100644 --- a/frontend/lib/models/couple_garde.dart +++ b/frontend/lib/models/couple_garde.dart @@ -39,13 +39,15 @@ class CoupleMembre { class CoupleGarde { final String id; final CoupleMembre enfant; - final CoupleMembre am; + final CoupleMembre? am; + final List parents; final bool courant; const CoupleGarde({ required this.id, required this.enfant, - required this.am, + this.am, + this.parents = const [], this.courant = false, }); @@ -55,9 +57,15 @@ class CoupleGarde { enfant: CoupleMembre.fromJson( Map.from(json['enfant'] ?? const {}), ), - am: CoupleMembre.fromJson( - Map.from(json['am'] ?? const {}), - ), + am: json['am'] != null + ? CoupleMembre.fromJson(Map.from(json['am'])) + : null, + parents: json['parents'] != null + ? (json['parents'] as List) + .whereType() + .map((e) => CoupleMembre.fromJson(Map.from(e))) + .toList() + : const [], courant: json['courant'] == true, ); } @@ -67,6 +75,7 @@ class CoupleGarde { id: id, enfant: enfant, am: am, + parents: parents, courant: courant ?? this.courant, ); } diff --git a/frontend/lib/screens/am/am_dashboard_screen.dart b/frontend/lib/screens/am/am_dashboard_screen.dart index fea0afe..959f656 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,150 @@ class _AmDashboardScreenState extends State { if (mounted) setState(() => _user = user); } + Future _loadCouples() async { + setState(() { + _couplesLoading = true; + _couplesError = null; + }); + try { + final res = await CoupleGardeService.getAmCouplesGarde(); + 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.definirAmCoupleCourant(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/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index 7e99b0d..34e427e 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -41,6 +41,7 @@ class _ParentRegisterStep3ScreenState extends State { CardColorVertical.red, CardColorVertical.green, CardColorVertical.blue, + CardColorVertical.aqua, ]; // Garder une trace des couleurs déjà utilisées diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index 6a35615..568d77f 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -3,13 +3,14 @@ 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/services/couple_garde_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/cartes_feed.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). -/// Métier cartes / blog / messagerie : tickets C/D/E. +/// Colonne gauche : sélecteur de couple (#167) + feed bulles (#173). class ParentDashboardScreen extends StatefulWidget { const ParentDashboardScreen({super.key}); @@ -62,7 +63,6 @@ class _ParentDashboardScreenState extends State { Future _selectCouple(CoupleGarde couple) async { if (couple.id == _selectedCoupleId) return; - // Optimiste : on bascule tout de suite, l'API persiste ensuite. setState(() => _selectedCoupleId = couple.id); try { final res = await CoupleGardeService.definirCoupleCourant(couple.id); @@ -115,6 +115,7 @@ class _ParentDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, + onAjouterBulle: () => _soon('Ajouter une bulle'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -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).', @@ -140,7 +138,7 @@ class _ParentDashboardScreenState extends State { } } -/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir). +/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173). class _LeftColumn extends StatelessWidget { final List couples; final String? selectedCoupleId; @@ -148,6 +146,7 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; + final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -156,6 +155,7 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, + required this.onAjouterBulle, }); @override @@ -174,12 +174,10 @@ class _LeftColumn extends StatelessWidget { onCoupleSelected: onCoupleSelected, ), const SizedBox(height: 14), - const Expanded( - child: QuotidienColumnPlaceholder( - title: 'Cartes', - subtitle: - 'Absences, congés AM, sorties à valider\n(à brancher — ticket #173).', - icon: Icons.style_outlined, + Expanded( + child: CartesFeed( + placementId: selectedCoupleId, + onAjouterBulle: onAjouterBulle, ), ), ], 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..2bef801 --- /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 + + '/evenements-agenda' + + (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}/evenements-agenda'); + 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}/evenements-agenda/$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}/evenements-agenda/$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..717af7f 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -67,7 +67,11 @@ class ApiConfig { static const String parentsCoupleGardeCourant = '/parents/me/couples-garde/courant'; static const String assistantesMaternelles = '/assistantes-maternelles'; - /// Création dossier AM actif par le staff (#156) — body type register AM. + /// Couples de garde de l'AM connectée (#170 / #171). + static const String assistantesMaternellesCouplesGarde = + '/assistantes-maternelles/me/couples-garde'; + static const String assistantesMaternellesCoupleGardeCourant = + '/assistantes-maternelles/me/couples-garde/courant'; static const String assistantesMaternellesDossier = '/assistantes-maternelles/dossier'; static const String enfants = '/enfants'; @@ -89,6 +93,10 @@ class ApiConfig { static const String conversations = '/conversations'; static const String notifications = '/notifications'; + /// File d'attention / bulles (#173 / #194). + static const String cards = '/cards'; + static const String cardsStream = '/cards/stream'; + // Headers static Map get headers => { 'Content-Type': 'application/json', @@ -96,7 +104,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/api/cards_service.dart b/frontend/lib/services/api/cards_service.dart new file mode 100644 index 0000000..14f64f9 --- /dev/null +++ b/frontend/lib/services/api/cards_service.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +/// Accès API Cartes (file d'attention) — tickets #173 / #194. +/// - GET /cards?placementId= +/// - POST /cards/:id/respond +class CardsService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + if (message is Map && message['message'] is String) { + return message['message'] as String; + } + } + } catch (_) {} + return fallback; + } + + /// Feed de bulles pour l'utilisateur (filtré par couple / placement). + static Future> lister({String? placementId}) async { + final query = {}; + if (placementId != null && placementId.isNotEmpty) { + query['placementId'] = placementId; + } + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.cards}', + ).replace(queryParameters: query.isEmpty ? null : query); + + final response = await http.get(uri, headers: await _headers()); + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement des cartes'), + ); + } + + final decoded = jsonDecode(response.body); + final list = (decoded is Map ? decoded['items'] as List? : null) ?? const []; + return list + .whereType() + .map((e) => CarteBulle.fromJson(Map.from(e))) + .toList(); + } + + /// Répondre : accept | refuse | ack. + static Future repondre({ + required String cardId, + required String action, + String? comment, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.cards}/$cardId/respond', + ), + headers: await _headers(), + body: jsonEncode({ + 'action': action, + if (comment != null && comment.trim().isNotEmpty) + 'comment': comment.trim(), + }), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur lors de la réponse'), + ); + } + + final decoded = jsonDecode(response.body); + return CarteBulle.fromJson(Map.from(decoded as Map)); + } +} diff --git a/frontend/lib/services/api/cards_stream_client.dart b/frontend/lib/services/api/cards_stream_client.dart new file mode 100644 index 0000000..35cf690 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_client.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +import 'cards_stream_connector_stub.dart' + if (dart.library.html) 'cards_stream_connector_web.dart' + if (dart.library.io) 'cards_stream_connector_io.dart'; + +/// Événement temps réel file d'attention — ticket #195. +class CardsStreamEvent { + final String type; + final String? cardId; + final CarteBulle? card; + + const CardsStreamEvent({ + required this.type, + this.cardId, + this.card, + }); +} + +/// Client SSE `GET /cards/stream` avec reconnexion. +class CardsStreamClient { + final void Function(CardsStreamEvent event) onEvent; + final VoidCallback? onReconnectSync; + + CardsStreamClient({ + required this.onEvent, + this.onReconnectSync, + }); + + final CardsStreamConnector _connector = CardsStreamConnector(); + bool _started = false; + bool _stopRequested = false; + int _attempt = 0; + Timer? _retryTimer; + bool _hadConnection = false; + + void start() { + if (_started) return; + _started = true; + _stopRequested = false; + _attempt = 0; + unawaited(_connect()); + } + + void stop() { + _stopRequested = true; + _started = false; + _retryTimer?.cancel(); + _retryTimer = null; + _connector.close(); + } + + Future _connect() async { + if (_stopRequested) return; + + final token = await TokenService.getToken(); + if (token == null || token.isEmpty) { + _scheduleRetry(); + return; + } + + final base = + '${ApiConfig.baseUrl}${ApiConfig.cardsStream}'.replaceAll( + RegExp(r'/+$'), + '', + ); + + // Web (EventSource) : token en query. IO : Bearer dans le connecteur. + final url = kIsWeb + ? Uri.parse(base).replace( + queryParameters: {'access_token': token}, + ).toString() + : base; + + await _connector.connect( + url: url, + bearerToken: kIsWeb ? null : token, + onEvent: _handleRaw, + onError: (_) { + if (_stopRequested) return; + _connector.close(); + _scheduleRetry(); + }, + ); + } + + void _handleRaw(String eventType, String data) { + if (_stopRequested) return; + + // Premier événement (souvent heartbeat) = connexion OK. + if (!_hadConnection) { + _hadConnection = true; + _attempt = 0; + } else if (eventType == 'heartbeat') { + _attempt = 0; + return; + } + + if (eventType == 'heartbeat') return; + + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return; + final map = Map.from(decoded); + final type = (map['event']?.toString().isNotEmpty == true) + ? map['event'].toString() + : eventType; + final cardId = map['card_id']?.toString(); + final payload = map['data']; + + CarteBulle? card; + if (type == 'response.added' && payload is Map) { + final nested = payload['card']; + if (nested is Map) { + card = CarteBulle.fromJson(Map.from(nested)); + } + } else if ((type == 'card.created' || type == 'card.updated') && + payload is Map) { + card = CarteBulle.fromJson(Map.from(payload)); + } + + onEvent( + CardsStreamEvent( + type: type, + cardId: cardId ?? card?.id, + card: card, + ), + ); + } catch (_) { + // Événement mal formé : ignorer. + } + } + + void _scheduleRetry() { + if (_stopRequested) return; + _retryTimer?.cancel(); + final delaySec = (1 << _attempt.clamp(0, 4)).clamp(1, 30); + _attempt++; + _retryTimer = Timer(Duration(seconds: delaySec), () { + if (_stopRequested) return; + if (_hadConnection) { + // Resync REST après coupure pour ne pas manquer d'événements. + onReconnectSync?.call(); + } + unawaited(_connect()); + }); + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_io.dart b/frontend/lib/services/api/cards_stream_connector_io.dart new file mode 100644 index 0000000..fca64f6 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_io.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Connecteur SSE via HTTP stream (IO / mobile) — ticket #195. +class CardsStreamConnector { + http.Client? _client; + StreamSubscription>? _sub; + bool _closed = false; + + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async { + close(); + _closed = false; + final client = http.Client(); + _client = client; + + try { + final request = http.Request('GET', Uri.parse(url)); + request.headers['Accept'] = 'text/event-stream'; + request.headers['Cache-Control'] = 'no-cache'; + if (bearerToken != null && bearerToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer $bearerToken'; + } + + final response = await client.send(request); + if (response.statusCode != 200) { + onError( + StateError('SSE cards HTTP ${response.statusCode}'), + ); + close(); + return; + } + + String? eventType; + final dataBuf = StringBuffer(); + final lineBuf = StringBuffer(); + + void flushEvent() { + if (dataBuf.isEmpty) { + eventType = null; + return; + } + onEvent(eventType ?? 'message', dataBuf.toString()); + dataBuf.clear(); + eventType = null; + } + + void handleLine(String line) { + if (line.isEmpty) { + flushEvent(); + return; + } + if (line.startsWith(':')) return; + if (line.startsWith('event:')) { + eventType = line.substring(6).trim(); + return; + } + if (line.startsWith('data:')) { + final payload = line.length > 5 ? line.substring(5) : ''; + final trimmed = payload.startsWith(' ') + ? payload.substring(1) + : payload; + if (dataBuf.isNotEmpty) dataBuf.write('\n'); + dataBuf.write(trimmed); + } + } + + _sub = response.stream.listen( + (chunk) { + if (_closed) return; + lineBuf.write(utf8.decode(chunk, allowMalformed: true)); + var content = lineBuf.toString(); + var idx = content.indexOf('\n'); + while (idx >= 0) { + var line = content.substring(0, idx); + if (line.endsWith('\r')) { + line = line.substring(0, line.length - 1); + } + handleLine(line); + content = content.substring(idx + 1); + idx = content.indexOf('\n'); + } + lineBuf + ..clear() + ..write(content); + }, + onError: (Object e) { + if (!_closed) onError(e); + }, + onDone: () { + if (!_closed) onError(StateError('SSE cards fermé')); + }, + cancelOnError: true, + ); + } catch (e) { + if (!_closed) onError(e); + close(); + } + } + + void close() { + _closed = true; + _sub?.cancel(); + _sub = null; + _client?.close(); + _client = null; + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_stub.dart b/frontend/lib/services/api/cards_stream_connector_stub.dart new file mode 100644 index 0000000..f50d0f8 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_stub.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +/// Connecteur SSE — stub (tests / plateformes sans impl). +class CardsStreamConnector { + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async {} + + void close() {} +} diff --git a/frontend/lib/services/api/cards_stream_connector_web.dart b/frontend/lib/services/api/cards_stream_connector_web.dart new file mode 100644 index 0000000..9e5e6b7 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_web.dart @@ -0,0 +1,60 @@ +// Connecteur web uniquement (import conditionnel). +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + +import 'dart:html' as html; + +/// Connecteur SSE navigateur (EventSource) — ticket #195. +/// Auth via `?access_token=` (EventSource ne permet pas Authorization). +class CardsStreamConnector { + html.EventSource? _source; + final Map _listeners = {}; + + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async { + close(); + final source = html.EventSource(url); + _source = source; + + const names = [ + 'card.created', + 'card.updated', + 'card.deleted', + 'response.added', + 'heartbeat', + ]; + for (final name in names) { + void listener(html.Event raw) { + final msg = raw as html.MessageEvent; + final data = msg.data?.toString() ?? ''; + if (data.isEmpty) return; + onEvent(name, data); + } + + _listeners[name] = listener; + source.addEventListener(name, listener); + } + + void onErr(html.Event _) { + onError(StateError('SSE cards déconnecté')); + } + + _listeners['error'] = onErr; + source.addEventListener('error', onErr); + } + + void close() { + final source = _source; + if (source != null) { + _listeners.forEach((name, listener) { + source.removeEventListener(name, listener); + }); + source.close(); + } + _listeners.clear(); + _source = null; + } +} 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/services/couple_garde_service.dart b/frontend/lib/services/couple_garde_service.dart index 83afcdf..974219a 100644 --- a/frontend/lib/services/couple_garde_service.dart +++ b/frontend/lib/services/couple_garde_service.dart @@ -73,4 +73,45 @@ class CoupleGardeService { Map.from(decoded as Map), ); } + + /// Liste les couples de garde et le couple courant de l'AM connectée. + static Future getAmCouplesGarde() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCouplesGarde}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement des couples de garde (AM)'), + ); + } + + final decoded = jsonDecode(response.body); + return CouplesGardeResponse.fromJson( + Map.from(decoded as Map), + ); + } + + /// Persiste le couple courant (AM) et renvoie la liste à jour. + static Future definirAmCoupleCourant( + String coupleId, + ) async { + final response = await http.put( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCoupleGardeCourant}'), + headers: await _headers(), + body: jsonEncode({'couple_id': coupleId}), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur sélection du couple de garde (AM)'), + ); + } + + final decoded = jsonDecode(response.body); + return CouplesGardeResponse.fromJson( + Map.from(decoded as Map), + ); + } } diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 7a9730e..8b830fa 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -17,6 +17,7 @@ class RepriseMapper { CardColorVertical.red, CardColorVertical.green, CardColorVertical.blue, + CardColorVertical.aqua, ]; static ParentData parentFromDossier(ParentDossier p) { diff --git a/frontend/lib/widgets/base_form_screen.dart b/frontend/lib/widgets/base_form_screen.dart index 608bffd..9b7cde4 100644 --- a/frontend/lib/widgets/base_form_screen.dart +++ b/frontend/lib/widgets/base_form_screen.dart @@ -185,6 +185,8 @@ class BaseFormScreen extends StatelessWidget { return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 4ff744f..14128f5 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -205,9 +205,14 @@ class _ChildCardWidgetState extends State { ); } - final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender - ? Colors.purple.shade200 - : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); + final Color baseCardColorForShadow = + widget.childData.cardColor == CardColorVertical.lavender + ? Colors.purple.shade200 + : (widget.childData.cardColor == CardColorVertical.pink + ? Colors.pink.shade200 + : (widget.childData.cardColor == CardColorVertical.aqua + ? Colors.teal.shade200 + : Colors.grey.shade200)); final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); final double editableCardWidth = config.isMobile ? double.infinity @@ -340,6 +345,7 @@ class _ChildCardWidgetState extends State { // Mapping manuel simple if (widget.childData.cardColor.path.contains('lavender')) horizontalCardAsset = CardColorHorizontal.lavender.path; + else if (widget.childData.cardColor.path.contains('aqua')) horizontalCardAsset = CardColorHorizontal.aqua.path; else if (widget.childData.cardColor.path.contains('blue')) horizontalCardAsset = CardColorHorizontal.blue.path; else if (widget.childData.cardColor.path.contains('green')) horizontalCardAsset = CardColorHorizontal.green.path; else if (widget.childData.cardColor.path.contains('lime')) horizontalCardAsset = CardColorHorizontal.lime.path; diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 94c9888..3a73267 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -1302,6 +1302,8 @@ class _PersonalInfoFormScreenState extends State { return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/presentation_form_screen.dart b/frontend/lib/widgets/presentation_form_screen.dart index fd2d310..fb019a6 100644 --- a/frontend/lib/widgets/presentation_form_screen.dart +++ b/frontend/lib/widgets/presentation_form_screen.dart @@ -642,6 +642,8 @@ class _PresentationFormScreenState extends State { return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 9a21bd0..33a282e 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1196,6 +1196,8 @@ class _ProfessionalInfoFormScreenState extends State return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/quotidien/carte.dart b/frontend/lib/widgets/quotidien/carte.dart new file mode 100644 index 0000000..bd3289e --- /dev/null +++ b/frontend/lib/widgets/quotidien/carte.dart @@ -0,0 +1,251 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Mode d’extension du fond pastel. +enum CarteStretch { + /// 9 zones (bulles / cartes content). + both, + + /// Uniquement horizontal — bouts en demi-cercle (bandeau couple). + horizontal, +} + +/// Fond pastel extensible (capacité par défaut des « cartes » UI). +/// +/// Coins / bouts à taille logique fixe ; le reste s’étire selon [stretch]. +class Carte extends StatefulWidget { + final String assetPath; + final CarteStretch stretch; + final Rect centerSlice; + final double cornerWidth; + final double cornerHeight; + final Widget? child; + final EdgeInsetsGeometry? padding; + final BoxConstraints? constraints; + final double? width; + final double? height; + + /// Carte / bulle content (`card_*_h.png`, ~1024×512). + const Carte({ + super.key, + required this.assetPath, + this.child, + this.padding, + this.constraints, + this.width, + this.height, + this.stretch = CarteStretch.both, + this.centerSlice = const Rect.fromLTRB(160, 90, 864, 422), + this.cornerWidth = 56, + this.cornerHeight = 40, + }); + + /// Bandeau couple enfant / nounou (`bandeau_*.png`, ~1024×160). + /// Étirement horizontal seul pour garder les bouts ronds. + const Carte.bandeau({ + super.key, + required this.assetPath, + this.child, + this.padding, + this.constraints, + this.width, + this.height, + }) : stretch = CarteStretch.horizontal, + // Demi-cercle source ≈ moitié de la hauteur (160/2 = 80). + centerSlice = const Rect.fromLTRB(80, 0, 944, 160), + cornerWidth = 45, + cornerHeight = 45; + + @override + State createState() => _CarteState(); +} + +class _CarteImageCache { + static final Map> _futures = {}; + + static Future load(String path) { + return _futures.putIfAbsent(path, () async { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec( + data.buffer.asUint8List(), + ); + final frame = await codec.getNextFrame(); + return frame.image; + }); + } +} + +class _CarteState extends State { + ui.Image? _image; + String? _loadedPath; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant Carte oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath) { + _load(); + } + } + + Future _load() async { + final path = widget.assetPath; + final image = await _CarteImageCache.load(path); + if (!mounted) return; + setState(() { + _image = image; + _loadedPath = path; + }); + } + + @override + Widget build(BuildContext context) { + final img = _image; + final ready = img != null && _loadedPath == widget.assetPath; + + return SizedBox( + width: widget.width, + height: widget.height, + child: Container( + constraints: widget.constraints, + child: CustomPaint( + painter: ready + ? _CartePainter( + image: img!, + stretch: widget.stretch, + centerSlice: widget.centerSlice, + cornerWidth: widget.cornerWidth, + cornerHeight: widget.cornerHeight, + ) + : null, + child: Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: widget.child, + ), + ), + ), + ); + } +} + +class _CartePainter extends CustomPainter { + final ui.Image image; + final CarteStretch stretch; + final Rect centerSlice; + final double cornerWidth; + final double cornerHeight; + + _CartePainter({ + required this.image, + required this.stretch, + required this.centerSlice, + required this.cornerWidth, + required this.cornerHeight, + }); + + @override + void paint(Canvas canvas, Size size) { + if (size.width <= 0 || size.height <= 0) return; + + final paint = Paint()..filterQuality = FilterQuality.high; + final srcW = image.width.toDouble(); + final srcH = image.height.toDouble(); + + void draw(Rect src, Rect dst) { + if (src.width <= 0 || src.height <= 0) return; + if (dst.width <= 0 || dst.height <= 0) return; + canvas.drawImageRect(image, src, dst, paint); + } + + if (stretch == CarteStretch.horizontal) { + // 3 bandes pleine hauteur : bout | milieu | bout (pilule). + // Largeur du bout = moitié de la hauteur → demi-cercle propre. + final tip = (size.height / 2).clamp(1.0, size.width / 2); + final srcTip = centerSlice.left.clamp(1.0, srcW / 2); + final midW = (size.width - tip * 2).clamp(0.0, size.width); + + draw( + Rect.fromLTWH(0, 0, srcTip, srcH), + Rect.fromLTWH(0, 0, tip, size.height), + ); + draw( + Rect.fromLTWH(srcTip, 0, (srcW - srcTip * 2).clamp(1.0, srcW), srcH), + Rect.fromLTWH(tip, 0, midW, size.height), + ); + draw( + Rect.fromLTWH(srcW - srcTip, 0, srcTip, srcH), + Rect.fromLTWH(tip + midW, 0, tip, size.height), + ); + return; + } + + // 9 zones (bulles). + final srcLeft = centerSlice.left.clamp(0.0, srcW); + final srcTop = centerSlice.top.clamp(0.0, srcH); + final srcRight = centerSlice.right.clamp(0.0, srcW); + final srcBottom = centerSlice.bottom.clamp(0.0, srcH); + + final srcLeftW = srcLeft; + final srcTopH = srcTop; + final srcRightW = srcW - srcRight; + final srcBottomH = srcH - srcBottom; + final srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW); + final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH); + + final dstLeftW = cornerWidth.clamp(1.0, size.width / 2); + final dstRightW = cornerWidth.clamp(1.0, size.width / 2); + final dstTopH = cornerHeight.clamp(1.0, size.height / 2); + final dstBottomH = cornerHeight.clamp(1.0, size.height / 2); + final dstCenterW = + (size.width - dstLeftW - dstRightW).clamp(0.0, size.width); + final dstCenterH = + (size.height - dstTopH - dstBottomH).clamp(0.0, size.height); + + // Haut + draw(Rect.fromLTWH(0, 0, srcLeftW, srcTopH), + Rect.fromLTWH(0, 0, dstLeftW, dstTopH)); + draw(Rect.fromLTWH(srcLeft, 0, srcCenterW, srcTopH), + Rect.fromLTWH(dstLeftW, 0, dstCenterW, dstTopH)); + draw(Rect.fromLTWH(srcRight, 0, srcRightW, srcTopH), + Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH)); + + // Milieu + draw(Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH), + Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH)); + draw(Rect.fromLTWH(srcLeft, srcTop, srcCenterW, srcCenterH), + Rect.fromLTWH(dstLeftW, dstTopH, dstCenterW, dstCenterH)); + draw(Rect.fromLTWH(srcRight, srcTop, srcRightW, srcCenterH), + Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH)); + + // Bas + draw(Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH), + Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH)); + draw(Rect.fromLTWH(srcLeft, srcBottom, srcCenterW, srcBottomH), + Rect.fromLTWH(dstLeftW, dstTopH + dstCenterH, dstCenterW, dstBottomH)); + draw( + Rect.fromLTWH(srcRight, srcBottom, srcRightW, srcBottomH), + Rect.fromLTWH( + dstLeftW + dstCenterW, + dstTopH + dstCenterH, + dstRightW, + dstBottomH, + ), + ); + } + + @override + bool shouldRepaint(covariant _CartePainter oldDelegate) { + return oldDelegate.image != image || + oldDelegate.stretch != stretch || + oldDelegate.centerSlice != centerSlice || + oldDelegate.cornerWidth != cornerWidth || + oldDelegate.cornerHeight != cornerHeight; + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart new file mode 100644 index 0000000..279b021 --- /dev/null +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -0,0 +1,1100 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:p_tits_pas/models/card_assets.dart'; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/services/api/cards_service.dart'; +import 'package:p_tits_pas/services/api/cards_stream_client.dart'; +import 'package:p_tits_pas/widgets/quotidien/carte.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; + +/// Feed de bulles (file d'attention) — ticket #173. +/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API. +class CartesFeed extends StatefulWidget { + final String? placementId; + final VoidCallback? onAjouterBulle; + + const CartesFeed({ + super.key, + this.placementId, + this.onAjouterBulle, + }); + + @override + State createState() => _CartesFeedState(); +} + +class _CartesFeedState extends State { + static const double _fadeExtent = 0.08; + + List _items = const []; + bool _loading = true; + String? _error; + String? _busyCardId; + + final ScrollController _scrollController = ScrollController(); + bool _isScrollable = false; + bool _fadeTop = false; + bool _fadeBottom = false; + + CardsStreamClient? _stream; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_syncScrollFades); + _load(); + _startStream(); + } + + @override + void dispose() { + _stream?.stop(); + _stream = null; + _scrollController.removeListener(_syncScrollFades); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant CartesFeed oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.placementId != widget.placementId) { + _load(); + } + } + + void _startStream() { + _stream?.stop(); + _stream = CardsStreamClient( + onEvent: _onStreamEvent, + onReconnectSync: () { + if (mounted) _load(); + }, + )..start(); + } + + void _onStreamEvent(CardsStreamEvent event) { + if (!mounted) return; + if (event.type == 'heartbeat') return; + + if (event.type == 'card.deleted') { + final id = event.cardId; + if (id == null || id.isEmpty) return; + setState(() { + _items = _trier([ + for (final c in _items) + if (c.id != id) c, + ]); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + return; + } + + final card = event.card; + if (card == null) return; + + final filter = widget.placementId; + final matchesFilter = filter == null || + filter.isEmpty || + card.idPlacement == filter; + + setState(() { + if (!matchesFilter) { + _items = _trier([ + for (final c in _items) + if (c.id != card.id) c, + ]); + } else if (_items.any((c) => c.id == card.id)) { + _items = _trier([ + for (final c in _items) if (c.id == card.id) card else c, + ]); + } else { + _items = _trier([..._items, card]); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + } + + void _syncScrollFades() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final scrollable = position.maxScrollExtent > 0.0; + final threshold = position.viewportDimension * _fadeExtent / 2; + final fadeTop = scrollable && position.pixels > threshold; + final fadeBottom = scrollable && + position.pixels < (position.maxScrollExtent - threshold); + if (scrollable != _isScrollable || + fadeTop != _fadeTop || + fadeBottom != _fadeBottom) { + setState(() { + _isScrollable = scrollable; + _fadeTop = fadeTop; + _fadeBottom = fadeBottom; + }); + } + } + + /// Aligné sur le tri API : refusées → ouvertes → traitées, puis + /// `modifie_le` DESC, `id` en départage (ordre stable entre parents). + static List _trier(List items) { + int rang(CarteStatut s) { + switch (s) { + case CarteStatut.refusee: + return 0; + case CarteStatut.ouverte: + return 1; + case CarteStatut.traitee: + return 2; + } + } + + final out = List.from(items); + out.sort((a, b) { + final byStatut = rang(a.statut).compareTo(rang(b.statut)); + if (byStatut != 0) return byStatut; + final am = a.modifieLe?.millisecondsSinceEpoch ?? 0; + final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0; + final byModif = bm.compareTo(am); // DESC + if (byModif != 0) return byModif; + return a.id.compareTo(b.id); + }); + return out; + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final items = await CardsService.lister( + placementId: widget.placementId, + ); + if (!mounted) return; + setState(() { + _items = _trier(items); + _loading = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString().replaceFirst('Exception: ', ''); + _loading = false; + }); + } + } + + Future _respond( + CarteBulle card, { + required String action, + String? comment, + }) async { + setState(() => _busyCardId = card.id); + try { + final updated = await CardsService.repondre( + cardId: card.id, + action: action, + comment: comment, + ); + if (!mounted) return; + setState(() { + _items = _trier([ + for (final c in _items) if (c.id == updated.id) updated else c, + ]); + _busyCardId = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _busyCardId = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + ), + ); + } + } + + Future _onAccept(CarteBulle card) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + 'Accepter la demande ?', + style: GoogleFonts.merienda(fontWeight: FontWeight.w600), + ), + content: Text( + card.isCongeAm + ? 'Confirmez-vous les congés ${_periodeDuAu(card)} ?' + : 'Confirmez-vous l’acceptation de « ${card.titreAffiche} » ' + '(${_periodeCourte(card)}) ?', + style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Confirmer'), + ), + ], + ), + ); + if (ok == true) { + await _respond(card, action: 'accept'); + } + } + + /// Ack immédiat (arrêt maladie, etc.) — pas de modale : un clic = bien reçu. + Future _onAck(CarteBulle card) => _respond(card, action: 'ack'); + + Future _onRefuse(CarteBulle card) async { + final comment = await showDialog( + context: context, + builder: (ctx) => _RefuseDialog( + title: card.isCongeAm ? 'Refuser les congés ?' : 'Refuser la demande', + message: card.isCongeAm + ? 'Indiquez le motif du refus des congés ' + '${_periodeDuAu(card)}.' + : 'Indiquez le motif du refus pour « ${card.titreAffiche} ».', + ), + ); + if (comment != null && comment.isNotEmpty) { + await _respond(card, action: 'refuse', comment: comment); + } + } + + static const _mois = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ]; + + static String _periodeCourte(CarteBulle card) { + final d = card.dateDebut; + final f = card.dateFin; + String fmt(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/${dt.year}'; + } + if (d == null && f == null) return 'période non précisée'; + if (d != null && f != null && d == f) return fmt(d); + return '${fmt(d)} → ${fmt(f)}'; + } + + /// « du 04 novembre 2026 au 06 novembre 2026 » + static String _periodeDuAu(CarteBulle card) { + String fmtLong(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final jj = dt.day.toString().padLeft(2, '0'); + return '$jj ${_mois[dt.month - 1]} ${dt.year}'; + } + + final d = card.dateDebut; + final f = card.dateFin; + if (d == null && f == null) return 'pour une période non précisée'; + if (d != null && (f == null || d == f)) return 'du ${fmtLong(d)}'; + return 'du ${fmtLong(d)} au ${fmtLong(f)}'; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon( + Icons.style_outlined, + color: QuotidienTheme.lavender, + size: 22, + ), + const SizedBox(width: 8), + Text( + 'Cartes', + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: widget.onAjouterBulle, + icon: const Icon(Icons.add, size: 18), + label: Text( + 'Ajouter', + style: GoogleFonts.merriweather(fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded(child: _buildBody()), + ], + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _error!, + textAlign: TextAlign.center, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), + ), + const SizedBox(height: 8), + TextButton(onPressed: _load, child: const Text('Réessayer')), + ], + ), + ); + } + if (_items.isEmpty) { + return Center( + child: Text( + 'Aucune bulle pour le moment.\nLa file d’attention est vide — c’est normal.', + textAlign: TextAlign.center, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.muted, + height: 1.4, + ), + ), + ); + } + + return NotificationListener( + onNotification: (_) { + _syncScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _fadeExtent, + 1.0 - _fadeExtent, + 1.0, + ]; + if (!_isScrollable) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: [0.0, 0.08, 0.92, 1.0], + ).createShader(bounds); + } + final topMask = _fadeTop ? Colors.transparent : Colors.black; + final bottomMask = _fadeBottom ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + topMask, + Colors.black, + Colors.black, + bottomMask, + ], + stops: stops, + ).createShader(bounds); + }, + child: RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.only(top: 4, bottom: 8), + itemCount: _items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final card = _items[index]; + return _BulleTile( + card: card, + busy: _busyCardId == card.id, + onAccept: () => _onAccept(card), + onAck: () => _onAck(card), + onRefuse: () => _onRefuse(card), + ); + }, + ), + ), + ), + ); + } +} + +/// Encre (titre / date / lien) accordée à la teinte de fond de la bulle. +class _Encre { + final Color ink; + final Color date; + final Color link; + const _Encre(this.ink, this.date, this.link); + + /// Aqua → pétrole désaturé ; pêche → brun chaud ; bleu → marine doux ; + /// rose → bois de rose ; lavande → prune. + static _Encre pour(CardColorHorizontal c) { + switch (c) { + case CardColorHorizontal.aqua: + return const _Encre( + Color(0xFF315F63), + Color(0xFF4A7276), + Color(0xFF2B5868), + ); + case CardColorHorizontal.peach: + return const _Encre( + Color(0xFF7A4A2E), + Color(0xFF8F6248), + Color(0xFF6E4530), + ); + case CardColorHorizontal.blue: + return const _Encre( + Color(0xFF2F4A6E), + Color(0xFF486083), + Color(0xFF2A4670), + ); + case CardColorHorizontal.pink: + return const _Encre( + Color(0xFF7A3E52), + Color(0xFF8E5668), + Color(0xFF6E3A50), + ); + case CardColorHorizontal.lavender: + return const _Encre( + Color(0xFF5C3D5C), + Color(0xFF70536F), + Color(0xFF553A60), + ); + case CardColorHorizontal.red: + case CardColorHorizontal.lime: + case CardColorHorizontal.green: + return const _Encre( + Color(0xFF2F2F2F), + Color(0xFF6B6B6B), + Color(0xFF3A3A3A), + ); + } + } +} + +class _BulleTile extends StatelessWidget { + final CarteBulle card; + final bool busy; + final VoidCallback onAccept; + final VoidCallback onAck; + final VoidCallback onRefuse; + + const _BulleTile({ + required this.card, + required this.busy, + required this.onAccept, + required this.onAck, + required this.onRefuse, + }); + + static const _mois = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ]; + + String _fmtLong(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final jj = dt.day.toString().padLeft(2, '0'); + return '$jj ${_mois[dt.month - 1]} ${dt.year}'; + } + + /// Une ligne type maquette : « Du 04 au 06 novembre 2026 ». + String _fmtPeriodeUneLigne() { + final d1 = DateTime.tryParse(card.dateDebut ?? ''); + final d2 = DateTime.tryParse(card.dateFin ?? ''); + if (d1 == null && d2 == null) return ''; + if (d1 != null && d2 == null) return 'Le ${_fmtLong(card.dateDebut)}'; + if (d1 == null && d2 != null) return 'Au ${_fmtLong(card.dateFin)}'; + final a = d1!; + final b = d2!; + final j1 = a.day.toString().padLeft(2, '0'); + final j2 = b.day.toString().padLeft(2, '0'); + if (a.year == b.year && a.month == b.month && a.day == b.day) { + return 'Le $j1 ${_mois[a.month - 1]} ${a.year}'; + } + if (a.year == b.year && a.month == b.month) { + return 'Du $j1 au $j2 ${_mois[a.month - 1]} ${a.year}'; + } + if (a.year == b.year) { + return 'Du $j1 ${_mois[a.month - 1]} au $j2 ${_mois[b.month - 1]} ${a.year}'; + } + return 'Du $j1 ${_mois[a.month - 1]} ${a.year} ' + 'au $j2 ${_mois[b.month - 1]} ${b.year}'; + } + + /// Arrêt maladie : reste opaque tant que la période n’est pas finie. + bool get _attenuee { + if (card.isArretMaladie && card.periodeEncoreActive) return false; + return card.statut == CarteStatut.traitee; + } + + /// Congés (Validée / Refusée) ou carte ack (Bien reçu). + bool get _montreStatutReponse { + if (card.peutRepondre) return false; + if (card.isCongeAm && + (card.statut == CarteStatut.traitee || + card.statut == CarteStatut.refusee)) { + return true; + } + return card.responseMode == CarteResponseMode.ack && + card.statut == CarteStatut.traitee; + } + + String get _libelleStatutReponse { + if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu'; + // Accordé à « Demande de congés ». + return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée'; + } + + @override + Widget build(BuildContext context) { + final periode = _fmtPeriodeUneLigne(); + final encre = _Encre.pour(card.colorFamily); + + return Opacity( + opacity: _attenuee ? 0.55 : 1, + child: Carte( + assetPath: card.backgroundAsset, + constraints: const BoxConstraints(minHeight: 140), + padding: const EdgeInsets.fromLTRB(22, 18, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.titreAffiche, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w700, + color: encre.ink, + ), + ), + if (periode.isNotEmpty) ...[ + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/cartes/icon_calendrier_crayon.png', + width: 28, + height: 28, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + color: encre.ink, + colorBlendMode: BlendMode.srcIn, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + periode, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 15, + fontWeight: FontWeight.w600, + color: encre.date, + height: 1.25, + ), + ), + ), + ], + ), + ], + if (card.isCongeAm && + card.statut == CarteStatut.refusee && + card.motifRefus != null) ...[ + const SizedBox(height: 8), + Text( + 'Motif : ${card.motifRefus}', + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merriweather( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.3, + color: encre.ink, + ), + ), + ] else if (card.statut == CarteStatut.ouverte && + card.isCreator && + (card.isCongeAm || + card.responseMode == CarteResponseMode.ack)) ...[ + const SizedBox(height: 8), + Text( + card.responseMode == CarteResponseMode.ack + ? 'En attente d’acquittement' + : 'En attente de réponse', + style: GoogleFonts.merriweather( + fontSize: 12, + color: QuotidienTheme.muted, + ), + ), + ], + ], + ), + ), + if (card.iconAsset != null) ...[ + const SizedBox(width: 8), + Image.asset( + card.iconAsset!, + width: 72, + height: 72, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ], + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (card.peutRepondre) + busy + ? const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : card.responseMode == CarteResponseMode.ack + ? _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_coche_pilule.png', + tooltip: 'Bien reçu', + onTap: onAck, + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_coche_pilule.png', + tooltip: 'Accepter', + onTap: onAccept, + ), + const SizedBox(width: 10), + _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_croix_pilule.png', + tooltip: 'Refuser', + onTap: onRefuse, + ), + ], + ) + else if (_montreStatutReponse) + Expanded( + child: Text( + _libelleStatutReponse, + textAlign: TextAlign.center, + style: GoogleFonts.merienda( + fontSize: 14, + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + color: encre.ink.withValues(alpha: 0.75), + ), + ), + ), + if (card.peutRepondre) const SizedBox(width: 8), + if (!_montreStatutReponse) const Spacer(), + InkWell( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Agenda — à venir.'), + duration: Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Text( + 'Ouvrir dans l’agenda ↗', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: GoogleFonts.merienda( + fontSize: 13, + fontWeight: FontWeight.w600, + color: encre.link, + decoration: TextDecoration.underline, + decorationColor: encre.link, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildGeneric(BuildContext context) { + final memeJour = card.dateDebut != null && + card.dateFin != null && + card.dateDebut == card.dateFin; + + return Opacity( + opacity: _attenuee ? 0.55 : 1, + child: Carte( + assetPath: card.backgroundAsset, + constraints: const BoxConstraints(minHeight: 120), + padding: const EdgeInsets.fromLTRB(22, 16, 22, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.titreAffiche, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w700, + color: QuotidienTheme.ink, + ), + ), + if (card.dateDebut != null || card.dateFin != null) ...[ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only(left: 12), + child: memeJour + ? Text( + 'Le ${_fmtLong(card.dateDebut)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Du ${_fmtLong(card.dateDebut)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ), + Text( + 'Au ${_fmtLong(card.dateFin)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ), + ], + ), + ), + ], + ], + ), + ), + if (card.iconAsset != null) ...[ + const SizedBox(width: 10), + Image.asset( + card.iconAsset!, + width: 80, + height: 80, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ], + ], + ), + if (card.statut == CarteStatut.refusee) ...[ + const SizedBox(height: 6), + Text( + card.lastRefuseComment != null && + card.lastRefuseComment!.trim().isNotEmpty + ? 'Refusé — ${card.lastRefuseComment}' + : 'Refusé', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merriweather( + fontSize: 12, + fontStyle: FontStyle.italic, + color: QuotidienTheme.ink, + ), + ), + ] else if (card.statut == CarteStatut.ouverte && + card.isCreator) ...[ + const SizedBox(height: 6), + Text( + 'En attente de réponse', + style: GoogleFonts.merriweather( + fontSize: 12, + color: QuotidienTheme.muted, + ), + ), + ], + if (card.peutRepondre) ...[ + const SizedBox(height: 14), + if (busy) + const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (card.responseMode == CarteResponseMode.acceptRefuse) + Row( + children: [ + Expanded( + child: _ActionPill( + asset: QuotidienTheme.pillTurquoiseAsset, + iconAsset: 'assets/images/coche.png', + tooltip: 'Accepter', + onTap: onAccept, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ActionPill( + asset: QuotidienTheme.pillCorailAsset, + iconAsset: 'assets/images/red_cross2.png', + tooltip: 'Refuser', + onTap: onRefuse, + ), + ), + ], + ) + else if (card.responseMode == CarteResponseMode.ack) + _ActionPill( + asset: QuotidienTheme.pillTurquoiseAsset, + iconAsset: 'assets/images/coche.png', + tooltip: 'Bien reçu', + onTap: onAck, + ), + ], + ], + ), + ), + ); + } +} + +/// Modale de refus : le controller vit avec le State (Échap / Annuler OK). +class _RefuseDialog extends StatefulWidget { + final String title; + final String message; + + const _RefuseDialog({ + required this.title, + required this.message, + }); + + @override + State<_RefuseDialog> createState() => _RefuseDialogState(); +} + +class _RefuseDialogState extends State<_RefuseDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.title, + style: GoogleFonts.merienda(fontWeight: FontWeight.w600), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.message, + style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + maxLines: 3, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Motif du refus…', + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final text = _controller.text.trim(); + if (text.isEmpty) return; + Navigator.pop(context, text); + }, + child: const Text('Refuser'), + ), + ], + ); + } +} + +/// Bouton pilule peinte (coche / croix intégrées) — carte congés. +class _PilulePeinteButton extends StatelessWidget { + final String asset; + final String tooltip; + final VoidCallback onTap; + + const _PilulePeinteButton({ + required this.asset, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Image.asset( + asset, + height: 36, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ); + } +} + +/// Bouton pilule dessiné (fond pastel + coche / croix). +class _ActionPill extends StatelessWidget { + final String asset; + final String iconAsset; + final String tooltip; + final VoidCallback onTap; + + const _ActionPill({ + required this.asset, + required this.iconAsset, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(24), + child: SizedBox( + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Image.asset(asset, fit: BoxFit.fill), + ), + Image.asset( + iconAsset, + height: 22, + width: 22, + fit: BoxFit.contain, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart index b34aa00..a910baa 100644 --- a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/couple_garde.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/quotidien/carte.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM @@ -37,7 +38,7 @@ class CoupleSelectorBandeau extends StatelessWidget { CoupleGarde? get _selected { if (couples.isEmpty) return null; - if (selectedCoupleId != null) { + if (selectedCoupleId != null) { for (final c in couples) { if (c.id == selectedCoupleId) return c; } @@ -133,16 +134,51 @@ class _CoupleCard extends StatelessWidget { required this.colorIndex, }); + /// Libellé parents côté AM : « Sophie » ou « Thomas et Claire ». + static String _parentsLabel(List parents) { + if (parents.isEmpty) return 'Parents'; + String prenomOf(CoupleMembre p) { + final prenom = (p.prenom ?? '').trim(); + if (prenom.isNotEmpty) return prenom; + return p.displayName(fallback: 'Parent'); + } + if (parents.length == 1) return prenomOf(parents.first); + return '${prenomOf(parents[0])} et ${prenomOf(parents[1])}'; + } + @override Widget build(BuildContext context) { - return Container( - height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]), - fit: BoxFit.fill, + // Parent mode: enfant | AM (photo + prénom) + // AM mode: enfant | parent(s) — texte seul, pas de vignette parent + final isParentMode = mode == CoupleBandeauMode.parent; + + Widget amOrParentsWidget; + + if (isParentMode) { + amOrParentsWidget = _MembreTile( + photoUrl: couple.am?.photoUrl, + name: (couple.am?.prenom != null && couple.am!.prenom!.isNotEmpty) + ? couple.am!.prenom! + : (couple.am?.displayName(fallback: 'Nounou') ?? 'Nounou'), + fallbackIcon: Icons.volunteer_activism, + ); + } else { + amOrParentsWidget = Text( + _parentsLabel(couple.parents), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 22, + fontWeight: FontWeight.bold, + color: QuotidienTheme.ink, ), - ), + ); + } + + return Carte.bandeau( + assetPath: QuotidienTheme + .bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length], + height: 90, padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ @@ -151,7 +187,8 @@ class _CoupleCard extends StatelessWidget { padding: const EdgeInsets.only(right: 12), child: _MembreTile( photoUrl: couple.enfant.photoUrl, - name: (couple.enfant.prenom != null && couple.enfant.prenom!.isNotEmpty) + name: (couple.enfant.prenom != null && + couple.enfant.prenom!.isNotEmpty) ? couple.enfant.prenom! : couple.enfant.displayName(fallback: 'Enfant'), fallbackIcon: Icons.child_care, @@ -171,29 +208,17 @@ class _CoupleCard extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.only(left: 12), - child: _MembreTile( - 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, - ), + child: amOrParentsWidget, ), ), SizedBox( width: 28, - child: showChevron - ? const Icon( - Icons.keyboard_arrow_down, - color: QuotidienTheme.ink, - ) - : null, + child: showChevron + ? const Icon( + Icons.keyboard_arrow_down, + color: QuotidienTheme.ink, + ) + : null, ), ], ), @@ -279,24 +304,22 @@ class _CoupleBandeauSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant - fit: BoxFit.fill, - colorFilter: ColorFilter.mode( - Colors.white.withValues(alpha: 0.5), - BlendMode.lighten, + return ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.white.withValues(alpha: 0.5), + BlendMode.lighten, + ), + child: Carte.bandeau( + assetPath: QuotidienTheme.bandeauLime, + height: 90, + child: const Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2), ), ), ), - alignment: Alignment.center, - child: const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2), - ), ); } } @@ -306,14 +329,9 @@ class _CoupleBandeauEmpty extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return Carte.bandeau( + assetPath: QuotidienTheme.bandeauLime, height: 90, - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage(QuotidienTheme.bandeauLime), // Un asset existant par défaut - fit: BoxFit.fill, - ), - ), padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ @@ -342,38 +360,35 @@ class _CoupleBandeauError extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant - fit: BoxFit.fill, - colorFilter: ColorFilter.mode( - QuotidienTheme.coral.withValues(alpha: 0.3), - BlendMode.srcATop, - ), - ), + return ColorFiltered( + colorFilter: ColorFilter.mode( + QuotidienTheme.coral.withValues(alpha: 0.3), + BlendMode.srcATop, ), - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: [ - const Icon(Icons.error_outline, color: QuotidienTheme.ink), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: GoogleFonts.merriweather( - fontSize: 13, - color: QuotidienTheme.ink, + child: Carte.bandeau( + assetPath: QuotidienTheme.bandeauLime, + height: 90, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.error_outline, color: QuotidienTheme.ink), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), ), ), - ), - if (onRetry != null) - TextButton( - onPressed: onRetry, - child: const Text('Réessayer'), - ), - ], + if (onRetry != null) + TextButton( + onPressed: onRetry, + child: const Text('Réessayer'), + ), + ], + ), ), ); } 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..9c99701 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); @@ -48,6 +49,7 @@ abstract final class QuotidienTheme { 'assets/images/bg_turquoise_pill.png'; static const String pillLavenderAsset = 'assets/images/bg_lavender_pill.png'; + static const String pillCorailAsset = 'assets/images/bg_corail_pill.png'; /// Trait crayon gris (2400×28, transparent) : séparateurs bandeau / corps / /// footer. Version verticale (28×2400) entre les colonnes. 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..ac1c4c5 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -30,13 +30,29 @@ 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 assets: - - assets/images/ # Déclarer le dossier entier - - assets/cards/ # Nouveau dossier de cartes + - assets/images/ + - assets/images/cartes/ + - assets/cards/ - assets/documents/ fonts: 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