diff --git a/backend/src/entities/evenements_agenda.entity.ts b/backend/src/entities/evenements_agenda.entity.ts index 33b693b..30db57c 100644 --- a/backend/src/entities/evenements_agenda.entity.ts +++ b/backend/src/entities/evenements_agenda.entity.ts @@ -14,6 +14,7 @@ export enum TypeEvenementAgendaType { ABSENCE_ENFANT = 'absence_enfant', CONGE_AM = 'conge_am', ARRET_MALADIE_AM = 'arret_maladie_am', + SOIN_ENFANT = 'soin_enfant', } export enum StatutEvenementAgendaType { diff --git a/backend/src/modules/cards/cards.controller.ts b/backend/src/modules/cards/cards.controller.ts index d1cfe21..a8cc3e6 100644 --- a/backend/src/modules/cards/cards.controller.ts +++ b/backend/src/modules/cards/cards.controller.ts @@ -82,7 +82,6 @@ export class CardsController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Répondre (accept / refuse / ack) — #194' }) @ApiBody({ type: RepondreCarteDto }) - @ApiResponse({ status: 200, type: CarteDto }) repondre( @User('id') userId: string, @User('role') role: RoleType, diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts index cfdb925..ebed7ab 100644 --- a/backend/src/modules/cards/cards.service.spec.ts +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -281,4 +281,96 @@ describe('CardsService (#194)', () => { ); expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); }); + + it('AM crée soin_enfant (fievre) → motif dérivé + payload structuré', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'soin_enfant', + system: true, + emitter_roles: [RoleType.ASSISTANTE_MATERNELLE], + response_mode: CardResponseModeType.ACK, + retention_days: 7, + audience_resolver: 'couple_parents', + }); + evenementsService.creer.mockResolvedValue({ id: 'ev-soin' }); + cardsRepo.create.mockImplementation((c) => ({ ...c, id: 'card-soin' })); + cardsRepo.save.mockResolvedValue({ id: 'card-soin' }); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-soin', + type_code: 'soin_enfant', + statut: CardInstanceStatutType.OUVERTE, + payload: { + date_debut: '2026-10-28', + date_fin: '2026-10-28', + motif: '38,5 °C à 14:30 (Médicament : Doliprane)', + sous_type: 'fievre', + heure_releve: '14:30', + temperature_c: 38.5, + medicament_donne: 'Doliprane', + }, + type: { response_mode: CardResponseModeType.ACK }, + }); + + const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, { + type_code: 'soin_enfant', + id_placement: 'pl-1', + date_debut: '2026-10-28', + date_fin: '2026-10-28', + payload: { + sous_type: 'fievre', + heure_releve: '14:30', + temperature_c: 38.5, + medicament_donne: 'Doliprane', + }, + }); + + expect(evenementsService.creer).toHaveBeenCalledWith( + 'am-1', + RoleType.ASSISTANTE_MATERNELLE, + expect.objectContaining({ + type: TypeEvenementAgendaType.SOIN_ENFANT, + motif: '38,5 °C à 14:30 (Médicament : Doliprane)', + }), + ); + expect(res.payload.motif).toBe('38,5 °C à 14:30 (Médicament : Doliprane)'); + }); + + it('AM corrige soin_enfant dans les 24h → historique + re-ack', async () => { + const creeLe = new Date(); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-soin', + type_code: 'soin_enfant', + id_placement: 'pl-1', + id_evenement: 'ev-soin', + cree_par: 'am-1', + cree_le: creeLe, + statut: CardInstanceStatutType.TRAITEE, // déjà acquitté + payload: { + date_debut: '2026-10-28', + date_fin: '2026-10-28', + sous_type: 'fievre', + heure_releve: '14:30', + temperature_c: 38.5, + }, + type: { retention_days: 7 }, + }); + evenementsService.maj.mockResolvedValue({}); + + await service.majEnAttente('am-1', RoleType.ASSISTANTE_MATERNELLE, 'card-soin', { + date_debut: '2026-10-28', + date_fin: '2026-10-28', + payload: { + sous_type: 'fievre', + heure_releve: '14:30', + temperature_c: 37.5, // correction + }, + }); + + expect(cardsRepo.manager.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE card_instances'), + expect.arrayContaining([ + CardInstanceStatutType.OUVERTE, // repasse ouverte + expect.stringContaining('37,5'), // nouveau payload stringifié + ]), + ); + }); }); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts index b0402a2..18efe6a 100644 --- a/backend/src/modules/cards/cards.service.ts +++ b/backend/src/modules/cards/cards.service.ts @@ -136,23 +136,76 @@ export class CardsService { let absenceId = dto.id_evenement; const absenceType = this.mapAbsenceType(dto.type_code); + let motif = dto.motif ?? null; + let finalPayload: Record = { + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif, + ...(dto.payload ?? {}), + }; + + if (dto.type_code === 'soin_enfant') { + const p = dto.payload as Record; + if (!p || !p?.sous_type || !p?.heure_releve) { + throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant'); + } + if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p?.heure_releve)) { + throw new BadRequestException('Format heure_releve invalide (HH:mm)'); + } + + if (p?.sous_type === 'fievre') { + if (typeof p?.temperature_c !== 'number' || p?.temperature_c < 34.0 || p?.temperature_c > 43.0) { + throw new BadRequestException('temperature_c invalide (34.0 - 43.0)'); + } + motif = `${p?.temperature_c.toFixed(1).replace('.', ',')} °C à ${p?.heure_releve}`; + if (p?.medicament_donne && typeof p?.medicament_donne === 'string' && p?.medicament_donne.trim()) { + motif += ` (Médicament : ${p?.medicament_donne.trim()})`; + } + } else if (p?.sous_type === 'bobo') { + const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre']; + if (!natures.includes(p?.bobo_nature)) { + throw new BadRequestException('bobo_nature invalide'); + } + motif = `Bobo (${p?.bobo_nature}) à ${p?.heure_releve}`; + if (p?.description && typeof p?.description === 'string' && p?.description.trim()) { + const desc = p?.description.trim(); + if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)'); + motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`; + } + } else if (p?.sous_type === 'medicament') { + if (!p?.medicament_nom || typeof p?.medicament_nom !== 'string' || !p?.medicament_nom.trim()) { + throw new BadRequestException('medicament_nom requis'); + } + motif = `Médicament (${p?.medicament_nom.trim()}) à ${p?.heure_releve}`; + if (p?.contexte && typeof p?.contexte === 'string' && p?.contexte.trim()) { + if (p?.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)'); + } + } else { + throw new BadRequestException('sous_type invalide pour soin_enfant'); + } + finalPayload.motif = motif; + } + 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, + motif: motif ? String(motif) : undefined, }); absenceId = absence.id; } else { + if (dto.type_code === 'soin_enfant') { + throw new BadRequestException('operation=update interdite pour soin_enfant'); + } 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, + motif: motif ? String(motif) : undefined, // Congé accepté modifié → repasse en attente via cartes respond flow; // pour absence enfant update immédiat déjà fait. ...(dto.type_code === 'conge_am' @@ -173,11 +226,7 @@ export class CardsService { cree_par: userId, operation, statut: statutInitial, - payload: { - date_debut: dto.date_debut, - date_fin: dto.date_fin, - motif: dto.motif ?? null, - }, + payload: finalPayload, purge_at: this.purgeAt(type.retention_days, statutInitial), }); const saved = await this.cardsRepo.save(card); @@ -280,11 +329,19 @@ export class CardsService { 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'); + + if (card.type_code === 'soin_enfant') { + const msSinceCreation = Date.now() - new Date(card.cree_le).getTime(); + if (msSinceCreation > 24 * 60 * 60 * 1000) { + throw new BadRequestException('Délai de correction de 24h dépassé'); + } + } else { + if ( + card.statut !== CardInstanceStatutType.OUVERTE && + card.statut !== CardInstanceStatutType.REFUSEE + ) { + throw new BadRequestException('Carte non modifiable dans cet état'); + } } const debut = @@ -294,21 +351,79 @@ export class CardsService { throw new BadRequestException('Dates invalides'); } + let motif = dto.motif ?? card.payload?.['motif'] ?? null; + let finalPayload: Record = { + ...card.payload, + date_debut: debut, + date_fin: fin, + ...(dto.payload ?? {}), + }; + + if (card.type_code === 'soin_enfant') { + const p = finalPayload; + if (!p || !p?.sous_type || !p?.heure_releve) { + throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant'); + } + if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p?.heure_releve)) { + throw new BadRequestException('Format heure_releve invalide (HH:mm)'); + } + + if (p?.sous_type === 'fievre') { + if (typeof p?.temperature_c !== 'number' || p?.temperature_c < 34.0 || p?.temperature_c > 43.0) { + throw new BadRequestException('temperature_c invalide (34.0 - 43.0)'); + } + motif = `${p?.temperature_c.toFixed(1).replace('.', ',')} °C à ${p?.heure_releve}`; + if (p?.medicament_donne && typeof p?.medicament_donne === 'string' && p?.medicament_donne.trim()) { + motif += ` (Médicament : ${p?.medicament_donne.trim()})`; + } + } else if (p?.sous_type === 'bobo') { + const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre']; + if (!natures.includes(p?.bobo_nature)) { + throw new BadRequestException('bobo_nature invalide'); + } + motif = `Bobo (${p?.bobo_nature}) à ${p?.heure_releve}`; + if (p?.description && typeof p?.description === 'string' && p?.description.trim()) { + const desc = p?.description.trim(); + if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)'); + motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`; + } + } else if (p?.sous_type === 'medicament') { + if (!p?.medicament_nom || typeof p?.medicament_nom !== 'string' || !p?.medicament_nom.trim()) { + throw new BadRequestException('medicament_nom requis'); + } + motif = `Médicament (${p?.medicament_nom.trim()}) à ${p?.heure_releve}`; + if (p?.contexte && typeof p?.contexte === 'string' && p?.contexte.trim()) { + if (p?.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)'); + } + } else { + throw new BadRequestException('sous_type invalide pour soin_enfant'); + } + finalPayload.motif = motif; + + const hist = Array.isArray(card.payload?.['historique']) ? card.payload['historique'] : []; + const oldPayload = { ...card.payload }; + delete oldPayload.historique; + hist.push({ + ...oldPayload, + corrige_le: new Date().toISOString(), + corrige_par: userId, + }); + finalPayload.historique = hist; + finalPayload.corrige_le = new Date().toISOString(); + } else { + finalPayload.motif = motif; + } + 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, + motif: motif ? String(motif) : undefined, + ...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}), }); } - card.payload = { - ...card.payload, - date_debut: debut, - date_fin: fin, - motif: dto.motif ?? card.payload?.['motif'] ?? null, - }; + card.payload = finalPayload; card.statut = CardInstanceStatutType.OUVERTE; card.purge_at = this.purgeAt(card.type.retention_days, card.statut); await this.cardsRepo.manager.query( @@ -332,16 +447,25 @@ export class CardsService { if (card.cree_par !== userId) { throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); } + + if (card.type_code === 'soin_enfant') { + const msSinceCreation = Date.now() - new Date(card.cree_le).getTime(); + if (msSinceCreation > 24 * 60 * 60 * 1000) { + throw new BadRequestException('Délai de correction de 24h dépassé, suppression impossible'); + } + } + 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 + // Supprime l’absence liée si pas encore acceptée définitivement (ou si soin_enfant dans les 24h) if ( card.statut === CardInstanceStatutType.OUVERTE || - card.statut === CardInstanceStatutType.REFUSEE + card.statut === CardInstanceStatutType.REFUSEE || + card.type_code === 'soin_enfant' ) { await this.evenementsService.supprimer(userId, role, card.id_evenement); } @@ -379,6 +503,9 @@ export class CardsService { if (typeCode === 'arret_maladie_am') { return TypeEvenementAgendaType.ARRET_MALADIE_AM; } + if (typeCode === 'soin_enfant') { + return TypeEvenementAgendaType.SOIN_ENFANT; + } throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`); } diff --git a/backend/src/modules/cards/dto/cards.dto.ts b/backend/src/modules/cards/dto/cards.dto.ts index d086dd5..a22c8a3 100644 --- a/backend/src/modules/cards/dto/cards.dto.ts +++ b/backend/src/modules/cards/dto/cards.dto.ts @@ -3,6 +3,7 @@ import { IsDateString, IsEnum, IsOptional, + IsObject, IsString, IsUUID, MaxLength, @@ -48,6 +49,11 @@ export class CreerCarteDto { @IsOptional() @IsEnum(CardOperationType) operation?: CardOperationType; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + payload?: Record; } export class RepondreCarteDto { @@ -79,6 +85,11 @@ export class MajCarteDto { @IsString() @MaxLength(2000) motif?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + payload?: Record; } export class CarteDto { diff --git a/backend/src/modules/evenements-agenda/evenements-agenda.service.ts b/backend/src/modules/evenements-agenda/evenements-agenda.service.ts index 1c88723..728e49c 100644 --- a/backend/src/modules/evenements-agenda/evenements-agenda.service.ts +++ b/backend/src/modules/evenements-agenda/evenements-agenda.service.ts @@ -196,7 +196,7 @@ export class EvenementsAgendaService { } private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType { - if (type === TypeEvenementAgendaType.ABSENCE_ENFANT) { + if (type === TypeEvenementAgendaType.ABSENCE_ENFANT || type === TypeEvenementAgendaType.SOIN_ENFANT) { return StatutEvenementAgendaType.ACCEPTE; } return StatutEvenementAgendaType.EN_ATTENTE; @@ -233,10 +233,11 @@ export class EvenementsAgendaService { if (role === RoleType.ASSISTANTE_MATERNELLE) { if ( type !== TypeEvenementAgendaType.CONGE_AM && - type !== TypeEvenementAgendaType.ARRET_MALADIE_AM + type !== TypeEvenementAgendaType.ARRET_MALADIE_AM && + type !== TypeEvenementAgendaType.SOIN_ENFANT ) { throw new ForbiddenException( - 'Une AM ne peut créer que congé ou arrêt maladie', + 'Une AM ne peut créer que congé, arrêt maladie ou soin enfant', ); } return; diff --git a/database/BDD.sql b/database/BDD.sql index cb5cea0..eaecc44 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -37,7 +37,7 @@ DO $$ BEGIN END IF; 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' + 'absence_enfant', 'conge_am', 'arret_maladie_am', 'soin_enfant' ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN diff --git a/database/migrations/2026_cards_system.sql b/database/migrations/2026_cards_system.sql index 4751c1b..63a73f5 100644 --- a/database/migrations/2026_cards_system.sql +++ b/database/migrations/2026_cards_system.sql @@ -108,6 +108,11 @@ INSERT INTO card_types ( 'arret_maladie_am', true, 'Arrêt maladie AM', ARRAY['assistante_maternelle'], ARRAY['parent'], 'couple_parents', 'ack', 14, 'pink' + ), + ( + 'soin_enfant', true, 'Soins enfant', + ARRAY['assistante_maternelle'], ARRAY['parent'], + 'couple_parents', 'ack', 7, 'pink' ) ON CONFLICT (code) DO UPDATE SET titre = EXCLUDED.titre, diff --git a/database/migrations/2026_soin_enfant_enum.sql b/database/migrations/2026_soin_enfant_enum.sql new file mode 100644 index 0000000..4d9f66e --- /dev/null +++ b/database/migrations/2026_soin_enfant_enum.sql @@ -0,0 +1,2 @@ +-- Migration incrémentale : ajout du type soin_enfant à l'agenda +ALTER TYPE type_evenement_agenda_type ADD VALUE IF NOT EXISTS 'soin_enfant'; diff --git a/frontend/assets/images/cartes/btn_ajouter_carte_pilule.png b/frontend/assets/images/cartes/btn_ajouter_carte_pilule.png new file mode 100644 index 0000000..0560bcd Binary files /dev/null and b/frontend/assets/images/cartes/btn_ajouter_carte_pilule.png differ diff --git a/frontend/assets/images/flacon.png b/frontend/assets/images/flacon.png new file mode 100644 index 0000000..8c09209 Binary files /dev/null and b/frontend/assets/images/flacon.png differ diff --git a/frontend/assets/images/pansement.png b/frontend/assets/images/pansement.png new file mode 100644 index 0000000..b26ab83 Binary files /dev/null and b/frontend/assets/images/pansement.png differ diff --git a/frontend/assets/images/thermo_verre.png b/frontend/assets/images/thermo_verre.png new file mode 100644 index 0000000..69c353c Binary files /dev/null and b/frontend/assets/images/thermo_verre.png differ diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart index d59e1a9..10df0c9 100644 --- a/frontend/lib/models/carte_bulle.dart +++ b/frontend/lib/models/carte_bulle.dart @@ -13,6 +13,7 @@ enum CarteStatut { switch (raw) { case 'refusee': return CarteStatut.refusee; + case 'fermee': case 'traitee': return CarteStatut.traitee; case 'ouverte': @@ -110,6 +111,57 @@ class CarteBulle { String? get dateFin => payload['date_fin']?.toString(); String? get motif => payload['motif']?.toString(); + /// Heure de relevé (payload soins), format API `HH:mm`. + String? get heureReleve => payload['heure_releve']?.toString(); + + /// Heure affichable `18h19` (depuis `heure_releve`). + String? get heureReleveAffichee { + final h = heureReleve?.trim(); + if (h == null || h.isEmpty) return null; + final parts = h.split(':'); + if (parts.length < 2) return h; + return '${parts[0]}h${parts[1]}'; + } + + /// Ligne détail soin (sans date/heure) — sous la ligne agenda. + String? get detailSoin { + if (!isSoinEnfant) return null; + final st = payload['sous_type']?.toString(); + if (st == 'fievre') { + final t = payload['temperature_c']; + final med = payload['medicament_donne']?.toString().trim(); + String? temp; + if (t is num) { + temp = t.toStringAsFixed(1).replaceAll('.', ','); + } else if (t != null) { + temp = t.toString().replaceAll('.', ','); + } + if (temp == null || temp.isEmpty) return motif; + var s = '$temp °C'; + if (med != null && med.isNotEmpty) { + s += ' (Médicament : $med)'; + } + return s; + } + if (st == 'bobo') { + final nature = payload['bobo_nature']?.toString() ?? 'bobo'; + final desc = payload['description']?.toString().trim(); + if (desc != null && desc.isNotEmpty) { + return 'Bobo ($nature) : $desc'; + } + return 'Bobo ($nature)'; + } + if (st == 'medicament') { + final nom = payload['medicament_nom']?.toString().trim(); + final ctx = payload['contexte']?.toString().trim(); + final label = + (nom != null && nom.isNotEmpty) ? 'Médicament ($nom)' : 'Médicament'; + if (ctx != null && ctx.isNotEmpty) return '$label : $ctx'; + return label; + } + return motif; + } + /// Motif de refus : réponse API, sinon motif payload (co-parent / reload). String? get motifRefus { final fromResponse = lastRefuseComment?.trim(); @@ -139,6 +191,21 @@ class CarteBulle { bool get isArretMaladie => typeCode == 'arret_maladie_am'; bool get isCongeAm => typeCode == 'conge_am'; bool get isSoinEnfant => typeCode == 'soin_enfant'; + bool get isAbsenceEnfant => + typeCode == 'absence_enfant' || typeCode == 'absence_enfant_modif'; + + /// Info seule (`response_mode: none`) : créée déjà `traitee`, mais reste + /// pertinente dans la file tant que la période n’est pas finie. + bool get restePleineDansFile { + if (statut == CarteStatut.ouverte) return true; + if (statut != CarteStatut.traitee) return false; + if (!periodeEncoreActive) return false; + // Arrêt maladie ack : opaque jusqu’à date_fin. + if (isArretMaladie) return true; + // Absence (et autres infos) : pas d’atténuation tant que période active. + if (responseMode == CarteResponseMode.none) return true; + return false; + } /// Icône dessin (haut droite) selon le type de bulle. String? get iconAsset { @@ -150,7 +217,12 @@ class CarteBulle { case 'absence_enfant': case 'absence_enfant_modif': return 'assets/images/sac_lapin.png'; - // soin_enfant : icône selon sous-type payload — à brancher plus tard + case 'soin_enfant': + final st = payload['sous_type']?.toString(); + if (st == 'fievre') return 'assets/images/thermo_verre.png'; + if (st == 'bobo') return 'assets/images/pansement.png'; + if (st == 'medicament') return 'assets/images/flacon.png'; + return null; default: return null; } diff --git a/frontend/lib/models/carte_type_ui.dart b/frontend/lib/models/carte_type_ui.dart new file mode 100644 index 0000000..133661a --- /dev/null +++ b/frontend/lib/models/carte_type_ui.dart @@ -0,0 +1,106 @@ +/// Configuration UI d'un type de carte — catalogue front (#175). +/// Les types émissibles viennent de l'API ; ce fichier affine libellés / hints. +enum CartePeriodeMode { + /// Plage date_debut → date_fin. + range, + + /// Une seule date (début = fin). + singleDay, +} + +class CarteSousTypeUi { + final String code; + final String label; + + const CarteSousTypeUi({required this.code, required this.label}); +} + +class CarteTypeUi { + final String typeCode; + final String label; + final String motifHint; + final bool motifRequired; + final CartePeriodeMode periodeMode; + final List sousTypes; + + const CarteTypeUi({ + required this.typeCode, + required this.label, + this.motifHint = 'Motif (optionnel)', + this.motifRequired = false, + this.periodeMode = CartePeriodeMode.range, + this.sousTypes = const [], + }); + + static const _catalog = { + 'absence_enfant': CarteTypeUi( + typeCode: 'absence_enfant', + label: 'Notification d’absence', + motifHint: 'Motif de l’absence (optionnel)', + ), + // absence_enfant_modif : hors scope cartes — via agenda uniquement. + 'conge_am': CarteTypeUi( + typeCode: 'conge_am', + label: 'Demande de congés', + motifHint: 'Précisions (optionnel)', + ), + 'arret_maladie_am': CarteTypeUi( + typeCode: 'arret_maladie_am', + label: 'Arrêt maladie', + motifHint: 'Précisions (optionnel)', + ), + 'soin_enfant': CarteTypeUi( + typeCode: 'soin_enfant', + label: 'Soin / bobo', + periodeMode: CartePeriodeMode.singleDay, + sousTypes: [ + CarteSousTypeUi(code: 'fievre', label: 'Fièvre'), + CarteSousTypeUi(code: 'bobo', label: 'Bobo'), + CarteSousTypeUi(code: 'medicament', label: 'Médicament'), + ], + ), + }; + + /// Fusionne un type API avec le catalogue ; fallback générique sinon. + static CarteTypeUi resolve({ + required String typeCode, + String? apiTitre, + }) { + final known = _catalog[typeCode]; + if (known != null) return known; + final titre = (apiTitre ?? '').trim(); + return CarteTypeUi( + typeCode: typeCode, + label: titre.isNotEmpty ? titre : typeCode, + motifHint: 'Motif (optionnel)', + ); + } +} + +/// Type SYSTEM émissible renvoyé par GET /cards/types. +class CarteTypeEmissible { + final String code; + final String titre; + final String? responseMode; + final CarteTypeUi ui; + + const CarteTypeEmissible({ + required this.code, + required this.titre, + this.responseMode, + required this.ui, + }); + + factory CarteTypeEmissible.fromJson(Map json) { + final code = (json['code'] ?? '').toString(); + final titre = (json['titre'] ?? code).toString(); + return CarteTypeEmissible( + code: code, + titre: titre, + responseMode: json['response_mode']?.toString(), + ui: CarteTypeUi.resolve(typeCode: code, apiTitre: titre), + ); + } + + String get label => ui.label; +} diff --git a/frontend/lib/screens/am/am_dashboard_screen.dart b/frontend/lib/screens/am/am_dashboard_screen.dart index 1dc0ed2..33dff73 100644 --- a/frontend/lib/screens/am/am_dashboard_screen.dart +++ b/frontend/lib/screens/am/am_dashboard_screen.dart @@ -11,7 +11,7 @@ import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart' /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). /// Colonne gauche : sélecteur couple (#170) + feed bulles (#174). -/// Création « Ajouter une bulle » (congé / arrêt) : ticket #176. +/// Création « Ajouter une carte » : modale générique (#175, réutilisée #176). class AmDashboardScreen extends StatefulWidget { const AmDashboardScreen({super.key}); @@ -117,7 +117,6 @@ class _AmDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, - onAjouterBulle: () => _soon('Ajouter une bulle (#176)'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -140,7 +139,7 @@ class _AmDashboardScreenState extends State { } } -/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174). +/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174 / #175). class _LeftColumn extends StatelessWidget { final List couples; final String? selectedCoupleId; @@ -148,7 +147,6 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; - final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -157,7 +155,6 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, - required this.onAjouterBulle, }); @override @@ -180,7 +177,6 @@ class _LeftColumn extends StatelessWidget { Expanded( child: CartesFeed( placementId: selectedCoupleId, - onAjouterBulle: onAjouterBulle, ), ), ], diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index 568d77f..fc9f15b 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -115,7 +115,6 @@ class _ParentDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, - onAjouterBulle: () => _soon('Ajouter une bulle'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -138,7 +137,7 @@ class _ParentDashboardScreenState extends State { } } -/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173). +/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 / #175). class _LeftColumn extends StatelessWidget { final List couples; final String? selectedCoupleId; @@ -146,7 +145,6 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; - final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -155,7 +153,6 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, - required this.onAjouterBulle, }); @override @@ -177,7 +174,6 @@ class _LeftColumn extends StatelessWidget { Expanded( child: CartesFeed( placementId: selectedCoupleId, - onAjouterBulle: onAjouterBulle, ), ), ], diff --git a/frontend/lib/services/api/cards_service.dart b/frontend/lib/services/api/cards_service.dart index 14f64f9..c505173 100644 --- a/frontend/lib/services/api/cards_service.dart +++ b/frontend/lib/services/api/cards_service.dart @@ -2,11 +2,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/models/carte_type_ui.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. +/// Accès API Cartes (file d'attention) — tickets #173 / #175 / #194. /// - GET /cards?placementId= +/// - GET /cards/types +/// - POST /cards /// - POST /cards/:id/respond class CardsService { static Future> _headers() async { @@ -57,6 +60,105 @@ class CardsService { .toList(); } + /// Types SYSTEM émissibles pour le rôle connecté. + static Future> listerTypes() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}/types'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement des types de cartes'), + ); + } + final decoded = jsonDecode(response.body); + final list = decoded is List + ? decoded + : (decoded is Map ? decoded['items'] as List? : null) ?? const []; + return list + .whereType() + .map((e) => CarteTypeEmissible.fromJson(Map.from(e))) + .where((t) => t.code.isNotEmpty) + // Modif d’absence = agenda, pas la modale cartes (#175). + .where((t) => t.code != 'absence_enfant_modif') + .toList(); + } + + /// Créer une bulle (+ événement agenda côté back). + static Future creer({ + required String typeCode, + required String idPlacement, + required String dateDebut, + required String dateFin, + String? motif, + Map? payload, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}'), + headers: await _headers(), + body: jsonEncode({ + 'type_code': typeCode, + 'id_placement': idPlacement, + 'date_debut': dateDebut, + 'date_fin': dateFin, + if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(), + if (payload != null) 'payload': payload, + }), + ); + + if (response.statusCode != 201 && response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur lors de la création'), + ); + } + + final decoded = jsonDecode(response.body); + return CarteBulle.fromJson(Map.from(decoded as Map)); + } + + /// Modifier une carte (corriger un soin, dates en attente...). + static Future maj({ + required String cardId, + String? dateDebut, + String? dateFin, + String? motif, + Map? payload, + }) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}/$cardId'), + headers: await _headers(), + body: jsonEncode({ + if (dateDebut != null) 'date_debut': dateDebut, + if (dateFin != null) 'date_fin': dateFin, + if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(), + if (payload != null) 'payload': payload, + }), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur lors de la mise à jour'), + ); + } + + final decoded = jsonDecode(response.body); + return CarteBulle.fromJson(Map.from(decoded as Map)); + } + + /// Supprimer une bulle (AM 24h ou avant traitement). + static Future supprimer(String cardId) async { + final response = await http.delete( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}/$cardId'), + headers: await _headers(), + ); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur lors de la suppression'), + ); + } + } + /// Répondre : accept | refuse | ack. static Future repondre({ required String cardId, @@ -75,7 +177,7 @@ class CardsService { }), ); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 201) { throw Exception( _extractError(response.body, 'Erreur lors de la réponse'), ); diff --git a/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart b/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart new file mode 100644 index 0000000..e0507de --- /dev/null +++ b/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart @@ -0,0 +1,704 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/models/carte_type_ui.dart'; +import 'package:p_tits_pas/services/api/cards_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; + +/// Ouvre la modale Material générique « Ajouter une carte » (#175). +/// Retourne la bulle créée, ou `null` si annulé. +Future showAjouterCarteModal( + BuildContext context, { + required String placementId, + CarteBulle? carteToEdit, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AjouterCarteModal( + placementId: placementId, + carteToEdit: carteToEdit, + ), + ); +} + +/// Modale générique : types API + catalogue UI, période, soins +class AjouterCarteModal extends StatefulWidget { + final String placementId; + final CarteBulle? carteToEdit; + + const AjouterCarteModal({ + super.key, + required this.placementId, + this.carteToEdit, + }); + + @override + State createState() => _AjouterCarteModalState(); +} + +class _AjouterCarteModalState extends State { + List _types = const []; + CarteTypeEmissible? _selected; + String? _sousTypeCode; + DateTime _dateDebut = DateTime.now(); + DateTime _dateFin = DateTime.now(); + bool _loadingTypes = true; + bool _submitting = false; + String? _error; + + TimeOfDay _heureReleve = TimeOfDay.now(); + + // --- Controllers Soins --- + final _motifController = TextEditingController(); + final _temperatureController = TextEditingController(); + final _medicamentDonneController = TextEditingController(); + String? _boboNature; + final _boboDescriptionController = TextEditingController(); + final _medicamentNomController = TextEditingController(); + final _medicamentContexteController = TextEditingController(); + + CarteTypeUi get _ui => + _selected?.ui ?? + CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu'); + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _dateDebut = DateTime(now.year, now.month, now.day); + _dateFin = _dateDebut; + _loadTypes(); + } + + void _prefillIfEdit(List types) { + if (widget.carteToEdit == null) return; + final card = widget.carteToEdit!; + final type = types.where((t) => t.code == card.typeCode).firstOrNull; + if (type != null) { + _selected = type; + } + + if (card.dateDebut != null) { + _dateDebut = DateTime.tryParse(card.dateDebut!) ?? _dateDebut; + } + if (card.dateFin != null) { + _dateFin = DateTime.tryParse(card.dateFin!) ?? _dateFin; + } + + _motifController.text = card.motif ?? ''; + + if (card.typeCode == 'soin_enfant') { + final p = card.payload; + _sousTypeCode = p['sous_type']?.toString(); + final h = p['heure_releve']?.toString(); + if (h != null && h.contains(':')) { + final parts = h.split(':'); + _heureReleve = TimeOfDay( + hour: int.tryParse(parts[0]) ?? 0, + minute: int.tryParse(parts[1]) ?? 0, + ); + } + + if (_sousTypeCode == 'fievre') { + _temperatureController.text = p['temperature_c']?.toString() ?? ''; + _medicamentDonneController.text = p['medicament_donne']?.toString() ?? ''; + } else if (_sousTypeCode == 'bobo') { + final nature = p['bobo_nature']?.toString() ?? ''; + final naturesValides = ['Chute', 'Coup', 'Égratignure', 'Piqûre', 'Brûlure', 'Autre']; + if (naturesValides.map((n) => n.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u')).contains(nature.toLowerCase())) { + _boboNature = naturesValides.firstWhere( + (n) => n.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u') == nature.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u'), + orElse: () => 'Autre' + ); + } else { + _boboNature = 'Autre'; + } + _boboDescriptionController.text = p['description']?.toString() ?? ''; + } else if (_sousTypeCode == 'medicament') { + _medicamentNomController.text = p['medicament_nom']?.toString() ?? ''; + _medicamentContexteController.text = p['contexte']?.toString() ?? ''; + } + } + } + + @override + void dispose() { + _temperatureController.dispose(); + _medicamentDonneController.dispose(); + _boboDescriptionController.dispose(); + _medicamentNomController.dispose(); + _medicamentContexteController.dispose(); + super.dispose(); + } + + Future _loadTypes() async { + setState(() { + _loadingTypes = true; + _error = null; + }); + try { + final types = await CardsService.listerTypes(); + if (!mounted) return; + setState(() { + _types = types; + _selected = types.isNotEmpty ? types.first : null; + _sousTypeCode = null; + _loadingTypes = false; + if (types.isEmpty) { + _error = 'Aucun type de carte disponible pour votre rôle.'; + } else { + _prefillIfEdit(types); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loadingTypes = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + String _fmtIso(DateTime d) { + final y = d.year.toString().padLeft(4, '0'); + final m = d.month.toString().padLeft(2, '0'); + final day = d.day.toString().padLeft(2, '0'); + return '$y-$m-$day'; + } + + String _fmtUi(DateTime d) { + final day = d.day.toString().padLeft(2, '0'); + final m = d.month.toString().padLeft(2, '0'); + return '$day/$m/${d.year}'; + } + + Future _pickTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _heureReleve, + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child ?? const SizedBox(), + ); + }, + ); + if (picked != null && mounted) { + setState(() => _heureReleve = picked); + } + } + + Future _pickSingleDay() async { + final picked = await showDatePicker( + context: context, + useRootNavigator: true, + initialDate: _dateDebut, + firstDate: DateTime(2020), + lastDate: DateTime(2040), + helpText: 'Date', + cancelText: 'Annuler', + confirmText: 'Valider', + ); + if (picked == null || !mounted) return; + final day = DateTime(picked.year, picked.month, picked.day); + setState(() { + _dateDebut = day; + _dateFin = day; + }); + } + + /// Plage Material (jours surlignés), en boîte compacte — pas plein écran. + /// Astuce : le mode calendrier de [DateRangePickerDialog] prend + /// `MediaQuery.size` comme taille de dialog ; on la bride. + Future _pickDateRange() async { + final initialEnd = + _dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin; + final picked = await showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: true, + builder: (dialogContext) { + final base = MediaQuery.of(dialogContext); + final theme = Theme.of(dialogContext); + return MediaQuery( + data: base.copyWith(size: const Size(420, 560)), + child: Theme( + data: theme.copyWith( + datePickerTheme: theme.datePickerTheme.copyWith( + rangePickerShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + dialogTheme: theme.dialogTheme.copyWith( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + child: DateRangePickerDialog( + initialDateRange: DateTimeRange( + start: _dateDebut, + end: initialEnd, + ), + firstDate: DateTime(2020), + lastDate: DateTime(2040), + currentDate: DateTime.now(), + helpText: 'Début, puis fin (même jour : 2×)', + saveText: 'Valider', + cancelText: 'Annuler', + ), + ), + ); + }, + ); + if (picked == null || !mounted) return; + setState(() { + _dateDebut = DateTime( + picked.start.year, + picked.start.month, + picked.start.day, + ); + _dateFin = DateTime( + picked.end.year, + picked.end.month, + picked.end.day, + ); + }); + } + + String? _sousTypeLabel() { + final sous = _sousTypeCode; + if (sous == null) return null; + for (final s in _ui.sousTypes) { + if (s.code == sous) return s.label; + } + return null; + } + + /// V1 : pas de texte libre pour le motif si la logique change. + /// (On retire `motifOptionnel` car le backend va dériver du payload). + Map? _buildPayload() { + if (_selected?.code != 'soin_enfant') { + return null; + } + + final base = { + 'sous_type': _sousTypeCode, + 'heure_releve': + '${_heureReleve.hour.toString().padLeft(2, '0')}:${_heureReleve.minute.toString().padLeft(2, '0')}', + }; + + if (_sousTypeCode == 'fievre') { + final t = _temperatureController.text.trim().replaceAll(',', '.'); + if (t.isNotEmpty) { + base['temperature_c'] = double.tryParse(t); + } + base['medicament_donne'] = _medicamentDonneController.text.trim(); + } else if (_sousTypeCode == 'bobo') { + // Normaliser l'enregistrement pour le back : + final nature = _boboNature?.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u') ?? 'autre'; + base['bobo_nature'] = nature; + base['description'] = _boboDescriptionController.text.trim(); + } else if (_sousTypeCode == 'medicament') { + base['medicament_nom'] = _medicamentNomController.text.trim(); + base['contexte'] = _medicamentContexteController.text.trim(); + } + + return base; + } + + Future _submit() async { + if (_selected == null) return; + + // Validations locales spécifiques soins + if (_selected?.code == 'soin_enfant') { + if (_sousTypeCode == null) { + setState(() => _error = 'Veuillez choisir un sous-type.'); + return; + } + if (_sousTypeCode == 'fievre') { + final t = _temperatureController.text.trim().replaceAll(',', '.'); + if (t.isNotEmpty && double.tryParse(t) == null) { + setState(() => _error = 'La température est invalide.'); + return; + } + } else if (_sousTypeCode == 'bobo' && _boboNature == null) { + setState(() => _error = 'Veuillez indiquer la nature du bobo.'); + return; + } else if (_sousTypeCode == 'medicament' && + _medicamentNomController.text.trim().isEmpty) { + setState(() => _error = 'Le nom du médicament est obligatoire.'); + return; + } + } + + if (_dateFin.isBefore(_dateDebut)) { + setState(() => _error = 'La date de fin doit être après la date de début.'); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + try { + final payload = _buildPayload(); + + final dynamic created; + if (widget.carteToEdit != null) { + created = await CardsService.maj( + cardId: widget.carteToEdit!.id, + dateDebut: _fmtIso(_dateDebut), + dateFin: _fmtIso( + _ui.periodeMode == CartePeriodeMode.singleDay + ? _dateDebut + : _dateFin, + ), + motif: _selected?.code == 'soin_enfant' ? null : _motifController.text.trim(), + payload: payload, + ); + } else { + created = await CardsService.creer( + typeCode: _selected!.code, + idPlacement: widget.placementId, + dateDebut: _fmtIso(_dateDebut), + dateFin: _fmtIso( + _ui.periodeMode == CartePeriodeMode.singleDay + ? _dateDebut + : _dateFin, + ), + motif: _selected?.code == 'soin_enfant' ? null : _motifController.text.trim(), + payload: payload, + ); + } + + if (!mounted) return; + Navigator.of(context).pop(created); + } catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Ajouter une carte', + style: GoogleFonts.merienda(fontWeight: FontWeight.w600), + ), + content: SizedBox( + width: 420, + child: _loadingTypes + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_error != null) ...[ + Text( + _error!, + style: GoogleFonts.merriweather( + fontSize: 13, + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 12), + ], + _buildTypeSelector(), + const SizedBox(height: 16), + if (_ui.periodeMode == CartePeriodeMode.singleDay) + Row( + children: [ + Expanded( + child: _DateField( + label: 'Date', + value: _fmtUi(_dateDebut), + onTap: _submitting ? null : _pickSingleDay, + ), + ), + if (_selected?.code == 'soin_enfant') ...[ + const SizedBox(width: 12), + Expanded( + child: _DateField( + label: 'Heure', + value: _heureReleve.format(context), + icon: Icons.access_time, + onTap: _submitting ? null : _pickTime, + ), + ), + ], + ], + ) + else + _DateField( + label: 'Période', + value: _dateDebut == _dateFin + ? _fmtUi(_dateDebut) + : '${_fmtUi(_dateDebut)} → ${_fmtUi(_dateFin)}', + onTap: _submitting ? null : _pickDateRange, + ), + if (_ui.sousTypes.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Sous-type', + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in _ui.sousTypes) + ChoiceChip( + label: Text(s.label), + selected: _sousTypeCode == s.code, + onSelected: _submitting + ? null + : (sel) { + setState(() { + _sousTypeCode = sel ? s.code : null; + }); + }, + ), + ], + ), + ], + if (_selected?.code == 'soin_enfant') _buildSoinForm(), + if (_selected?.code != 'soin_enfant') ...[ + const SizedBox(height: 16), + TextFormField( + controller: _motifController, + enabled: !_submitting, + maxLines: 3, + decoration: InputDecoration( + labelText: _ui.motifRequired ? 'Motif *' : 'Motif', + hintText: _ui.motifHint, + border: const OutlineInputBorder(), + ), + validator: (v) { + if (!_ui.motifRequired) return null; + final text = v?.trim() ?? ''; + if (text.isEmpty) return 'Motif obligatoire'; + return null; + }, + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _submitting ? null : () => Navigator.pop(context), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: (_submitting || + _loadingTypes || + _selected == null || + _types.isEmpty) + ? null + : _submit, + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Envoyer'), + ), + ], + ); + } + + Widget _buildTypeSelector() { + if (_types.isEmpty) { + return Text( + 'Aucun type disponible.', + style: GoogleFonts.merriweather(fontSize: 13), + ); + } + if (_types.length == 1) { + return Text( + _types.first.label, + style: GoogleFonts.merienda( + fontSize: 16, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + ), + ); + } + return DropdownButtonFormField( + value: _selected?.code, + decoration: const InputDecoration( + labelText: 'Type de carte', + border: OutlineInputBorder(), + ), + items: [ + for (final t in _types) + DropdownMenuItem(value: t.code, child: Text(t.label)), + ], + onChanged: _submitting + ? null + : (code) { + if (code == null) return; + CarteTypeEmissible? next; + for (final t in _types) { + if (t.code == code) { + next = t; + break; + } + } + if (next == null) return; + setState(() { + _selected = next; + _sousTypeCode = null; + if (next!.ui.periodeMode == CartePeriodeMode.singleDay) { + _dateFin = _dateDebut; + } + }); + }, + ); + } + Widget _buildSoinForm() { + if (_sousTypeCode == null) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + if (_sousTypeCode == 'fievre') ...[ + TextFormField( + controller: _temperatureController, + enabled: !_submitting, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Température (°C) *', + hintText: 'ex: 38.5', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _medicamentDonneController, + enabled: !_submitting, + decoration: const InputDecoration( + labelText: 'Médicament donné (optionnel)', + hintText: 'ex: Doliprane 2.4kg', + border: OutlineInputBorder(), + ), + ), + ] else if (_sousTypeCode == 'bobo') ...[ + DropdownButtonFormField( + value: _boboNature, + decoration: const InputDecoration( + labelText: 'Nature du bobo *', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'Chute', child: Text('Chute')), + DropdownMenuItem(value: 'Égratignure', child: Text('Égratignure')), + DropdownMenuItem(value: 'Coup', child: Text('Coup')), + DropdownMenuItem(value: 'Piqûre', child: Text('Piqûre')), + DropdownMenuItem(value: 'Brûlure', child: Text('Brûlure')), + DropdownMenuItem(value: 'Autre', child: Text('Autre')), + ], + onChanged: _submitting + ? null + : (val) { + setState(() { + _boboNature = val; + }); + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _boboDescriptionController, + enabled: !_submitting, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Description / Zone touchée *', + hintText: 'ex: Égratignure sur le genou droit', + border: OutlineInputBorder(), + ), + ), + ] else if (_sousTypeCode == 'medicament') ...[ + TextFormField( + controller: _medicamentNomController, + enabled: !_submitting, + decoration: const InputDecoration( + labelText: 'Nom du médicament *', + hintText: 'ex: Ventoline', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _medicamentContexteController, + enabled: !_submitting, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Contexte / Posologie (optionnel)', + hintText: 'ex: 2 bouffées suite à une quinte de toux', + border: OutlineInputBorder(), + ), + ), + ], + ], + ); + } +} + +class _DateField extends StatelessWidget { + final String label; + final String value; + final VoidCallback? onTap; + final IconData icon; + + const _DateField({ + required this.label, + required this.value, + required this.onTap, + this.icon = Icons.calendar_today_outlined, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: Icon(icon, size: 18), + ), + child: Text( + value, + style: GoogleFonts.merriweather(fontSize: 14), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index f71dcc2..0573d34 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -7,6 +7,7 @@ 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/services/auth_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/ajouter_carte_modal.dart'; import 'package:p_tits_pas/widgets/quotidien/carte.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; @@ -14,6 +15,8 @@ import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Widget partagé ; les actions dépendent du rôle + payload API. class CartesFeed extends StatefulWidget { final String? placementId; + + /// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal]. final VoidCallback? onAjouterBulle; const CartesFeed({ @@ -171,24 +174,25 @@ class _CartesFeedState extends State { } } - /// Tri stable partagé : refusées → ouvertes → traitées, puis - /// `cree_le` DESC + `id` (pas `modifie_le` : une validation ne - /// re-mélange plus la file différemment selon les clients). + /// Tri stable partagé : refusées → encore actives (ouvertes / infos + /// période en cours) → traitées passées, puis `cree_le` DESC + `id`. static List _trier(List items) { - int rang(CarteStatut s) { - switch (s) { + int rang(CarteBulle c) { + switch (c.statut) { case CarteStatut.refusee: return 0; case CarteStatut.ouverte: return 1; case CarteStatut.traitee: - return 2; + // Absence info / arrêt encore dans la période : même rang que + // ouverte (sinon elles tombent en bas dès la création). + return c.restePleineDansFile ? 1 : 2; } } final out = List.from(items); out.sort((a, b) { - final byStatut = rang(a.statut).compareTo(rang(b.statut)); + final byStatut = rang(a).compareTo(rang(b)); if (byStatut != 0) return byStatut; final ac = a.creeLe?.millisecondsSinceEpoch ?? 0; final bc = b.creeLe?.millisecondsSinceEpoch ?? 0; @@ -372,23 +376,42 @@ class _CartesFeedState extends State { 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()), + _AjouterCarteCta(onTap: _onAjouterCarte), ], ); } + Future _onAjouterCarte() async { + if (widget.onAjouterBulle != null) { + widget.onAjouterBulle!(); + return; + } + final placementId = widget.placementId; + if (placementId == null || placementId.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sélectionnez un couple de garde pour ajouter une carte.', + ), + ), + ); + return; + } + final created = await showAjouterCarteModal( + context, + placementId: placementId, + ); + if (created != null && mounted) { + // SSE peut précéder ; refresh pour garantir la cohérence locale. + await _load(); + } + } + Widget _buildBody() { if (_loading) { return const Center( @@ -477,7 +500,7 @@ class _CartesFeedState extends State { onRefresh: _load, child: ListView.separated( controller: _scrollController, - padding: const EdgeInsets.only(top: 4, bottom: 8), + padding: const EdgeInsets.only(top: 4, bottom: 4), itemCount: _items.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { @@ -588,8 +611,18 @@ class _BulleTile extends StatelessWidget { return '$jj ${_mois[dt.month - 1]} ${dt.year}'; } - /// Une ligne type maquette : « Du 04 au 06 novembre 2026 ». String _fmtPeriodeUneLigne() { + // Soin : « Le 08 octobre 2026 à 18h19 » (détail en italique sous la ligne). + if (card.isSoinEnfant) { + final jour = card.dateDebut != null + ? 'Le ${_fmtLong(card.dateDebut)}' + : ''; + final h = card.heureReleveAffichee; + if (jour.isEmpty) return h != null ? 'À $h' : ''; + if (h == null || h.isEmpty) return jour; + return '$jour à $h'; + } + final d1 = DateTime.tryParse(card.dateDebut ?? ''); final d2 = DateTime.tryParse(card.dateFin ?? ''); if (d1 == null && d2 == null) return ''; @@ -612,9 +645,10 @@ class _BulleTile extends StatelessWidget { 'au $j2 ${_mois[b.month - 1]} ${b.year}'; } - /// Arrêt maladie : reste opaque tant que la période n’est pas finie. + /// Arrêt / absence info : reste opaque tant que la période n’est pas finie + /// (même si `traitee` — absence = info seule dès la création). bool get _attenuee { - if (card.isArretMaladie && card.periodeEncoreActive) return false; + if (card.restePleineDansFile) return false; return card.statut == CarteStatut.traitee; } @@ -692,8 +726,6 @@ class _BulleTile extends StatelessWidget { Expanded( child: Text( periode, - maxLines: 2, - overflow: TextOverflow.ellipsis, style: GoogleFonts.merienda( fontSize: 15, fontWeight: FontWeight.w600, @@ -705,14 +737,27 @@ class _BulleTile extends StatelessWidget { ], ), ], + if (card.isSoinEnfant && + (card.detailSoin?.trim().isNotEmpty ?? false)) ...[ + const SizedBox(height: 8), + Text( + card.detailSoin!, + softWrap: true, + style: GoogleFonts.merriweather( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.35, + color: encre.ink, + ), + ), + ], if (card.isCongeAm && card.statut == CarteStatut.refusee && card.motifRefus != null) ...[ const SizedBox(height: 8), Text( 'Motif : ${card.motifRefus}', - maxLines: 4, - overflow: TextOverflow.ellipsis, + softWrap: true, style: GoogleFonts.merriweather( fontSize: 13, fontStyle: FontStyle.italic, @@ -826,6 +871,11 @@ class _BulleTile extends StatelessWidget { } Widget _buildGeneric(BuildContext context) { + final encre = _Encre.pour(card.colorFamily); + final isEdited = card.modifieLe != null && (card.creeLe == null || card.modifieLe!.isAfter(card.creeLe!)); + final createdAt = card.creeLe ?? DateTime.now(); + final canEdit = card.isCreator && DateTime.now().difference(createdAt).inHours < 24; + final memeJour = card.dateDebut != null && card.dateFin != null && card.dateDebut == card.dateFin; @@ -846,23 +896,41 @@ class _BulleTile extends StatelessWidget { 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, - ), + Row( + children: [ + Expanded( + child: Text( + card.titreAffiche, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w700, + color: QuotidienTheme.ink, + ), + ), + ), + if (isEdited) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + '(Corrigée)', + style: GoogleFonts.merriweather( + fontSize: 11, + fontStyle: FontStyle.italic, + color: QuotidienTheme.ink.withOpacity(0.6), + ), + ), + ), + ], ), - if (card.dateDebut != null || card.dateFin != null) ...[ + if (card.dateDebut != null || card.dateFin != null || card.isSoinEnfant) ...[ const SizedBox(height: 10), Padding( padding: const EdgeInsets.only(left: 12), - child: memeJour + child: (memeJour || card.isSoinEnfant) ? Text( - 'Le ${_fmtLong(card.dateDebut)}', + _fmtPeriodeUneLigne(), style: GoogleFonts.merienda( fontSize: 17, fontWeight: FontWeight.w600, @@ -936,6 +1004,72 @@ class _BulleTile extends StatelessWidget { ), ), ], + + // Edit / Delete buttons for creator if within 24h + if (canEdit && !busy) ...[ + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + icon: const Icon(Icons.edit, size: 16), + label: const Text('Corriger'), + style: TextButton.styleFrom( + foregroundColor: encre.link, + textStyle: GoogleFonts.merriweather(fontSize: 12), + ), + onPressed: () { + showAjouterCarteModal( + context, + placementId: card.idPlacement, + carteToEdit: card, + ); + }, + ), + TextButton.icon( + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('Supprimer'), + style: TextButton.styleFrom( + foregroundColor: Colors.red[700], + textStyle: GoogleFonts.merriweather(fontSize: 12), + ), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Supprimer cette carte ?', style: GoogleFonts.merienda()), + content: const Text('Cette action est définitive.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red[700]), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + try { + await CardsService.supprimer(card.id); + // The stream will handle the removal locally via card.deleted + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: ${e.toString().replaceFirst('Exception: ', '')}')), + ); + } + } + } + }, + ), + ], + ), + ], + if (card.peutRepondre) ...[ const SizedBox(height: 14), if (busy) @@ -1057,6 +1191,45 @@ class _RefuseDialogState extends State<_RefuseDialog> { } } +/// CTA « Ajouter une carte » — pilule aquarelle en bas du feed (#175). +class _AjouterCarteCta extends StatelessWidget { + final VoidCallback onTap; + + const _AjouterCarteCta({required this.onTap}); + + static const _asset = + 'assets/images/cartes/btn_ajouter_carte_pilule.png'; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 14), + child: Center( + child: Tooltip( + message: 'Ajouter une carte', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(28), + child: Semantics( + button: true, + label: 'Ajouter une carte', + child: Image.asset( + _asset, + height: 48, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ), + ), + ), + ), + ); + } +} + /// Bouton pilule peinte (coche / croix intégrées) — carte congés. class _PilulePeinteButton extends StatelessWidget { final String asset;