Compare commits

...
Author SHA1 Message Date
jmartinandCursor fb35075614 feat(front): alignement avec la spec backend soins enfant
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:13:58 +02:00
jmartinandCursor 19b5b9e19c feat(front): implémentation UI soins enfant (#207)
- Ajout des formulaires spécifiques (fièvre, bobo, médicament) dans la modale d'ajout
- Formatage personnalisé du résumé des soins dans le feed
- Boutons supprimer / corriger (24h) pour l'AM
- Icônes contextuelles selon le sous-type de soin

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:54:58 +02:00
jmartinandCursor 8af579be53 feat(#206): backend cartes rose soin_enfant (3 sous-types, instant T, correction 24h)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 16:46:54 +02:00
jmartinandCursor a36e9ef176 feat(#175,#176): modale générique Ajouter une carte + CTA pilule
Creation absence (parent) et conge/arret (AM) via une seule modale
Material, catalogue UI extensible, calendrier de periode compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 22:36:29 +02:00
jmartin 5d36b0f4c1 Merge branch 'feature/174-feed-bulles-am' into develop
#174 feed bulles AM (widget partagé + polish UX).
2026-10-05 16:16:41 +02:00
jmartinandCursor ad48ab28cc feat(#174): feed bulles AM + libellés statut / tri stable
Branche CartesFeed sur le TdB AM ; Vu / En attente de validation ;
préserve is_creator SSE et tri par cree_le pour limiter les écarts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 16:16:36 +02:00
jmartin 342686f4e0 Merge branch 'feature/173-feed-bulles-parent' into develop
#173 feed bulles parent : UI, ack/SSE, correctifs cards.
2026-10-05 15:41:46 +02:00
16 changed files with 1495 additions and 109 deletions
@@ -14,6 +14,7 @@ export enum TypeEvenementAgendaType {
ABSENCE_ENFANT = 'absence_enfant', ABSENCE_ENFANT = 'absence_enfant',
CONGE_AM = 'conge_am', CONGE_AM = 'conge_am',
ARRET_MALADIE_AM = 'arret_maladie_am', ARRET_MALADIE_AM = 'arret_maladie_am',
SOIN_ENFANT = 'soin_enfant',
} }
export enum StatutEvenementAgendaType { export enum StatutEvenementAgendaType {
@@ -281,4 +281,96 @@ describe('CardsService (#194)', () => {
); );
expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); 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é
]),
);
});
}); });
+144 -17
View File
@@ -136,23 +136,76 @@ export class CardsService {
let absenceId = dto.id_evenement; let absenceId = dto.id_evenement;
const absenceType = this.mapAbsenceType(dto.type_code); const absenceType = this.mapAbsenceType(dto.type_code);
let motif = dto.motif ?? null;
let finalPayload = {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif,
...(dto.payload ?? {}),
};
if (dto.type_code === 'soin_enfant') {
const p = dto.payload;
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) { if (operation === CardOperationType.CREATE) {
const absence = await this.evenementsService.creer(userId, role, { const absence = await this.evenementsService.creer(userId, role, {
id_placement: dto.id_placement, id_placement: dto.id_placement,
type: absenceType, type: absenceType,
date_debut: dto.date_debut, date_debut: dto.date_debut,
date_fin: dto.date_fin, date_fin: dto.date_fin,
motif: dto.motif, motif: motif ?? undefined,
}); });
absenceId = absence.id; absenceId = absence.id;
} else { } else {
if (dto.type_code === 'soin_enfant') {
throw new BadRequestException('operation=update interdite pour soin_enfant');
}
if (!absenceId) { if (!absenceId) {
throw new BadRequestException('id_evenement requis pour operation=update'); throw new BadRequestException('id_evenement requis pour operation=update');
} }
await this.evenementsService.maj(userId, role, absenceId, { await this.evenementsService.maj(userId, role, absenceId, {
date_debut: dto.date_debut, date_debut: dto.date_debut,
date_fin: dto.date_fin, date_fin: dto.date_fin,
motif: dto.motif, motif: motif ?? undefined,
// Congé accepté modifié → repasse en attente via cartes respond flow; // Congé accepté modifié → repasse en attente via cartes respond flow;
// pour absence enfant update immédiat déjà fait. // pour absence enfant update immédiat déjà fait.
...(dto.type_code === 'conge_am' ...(dto.type_code === 'conge_am'
@@ -173,11 +226,7 @@ export class CardsService {
cree_par: userId, cree_par: userId,
operation, operation,
statut: statutInitial, statut: statutInitial,
payload: { payload: finalPayload,
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif ?? null,
},
purge_at: this.purgeAt(type.retention_days, statutInitial), purge_at: this.purgeAt(type.retention_days, statutInitial),
}); });
const saved = await this.cardsRepo.save(card); const saved = await this.cardsRepo.save(card);
@@ -280,12 +329,20 @@ export class CardsService {
if (card.cree_par !== userId) { if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut modifier cette carte'); throw new ForbiddenException('Seul le créateur peut modifier 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é');
}
} else {
if ( if (
card.statut !== CardInstanceStatutType.OUVERTE && card.statut !== CardInstanceStatutType.OUVERTE &&
card.statut !== CardInstanceStatutType.REFUSEE card.statut !== CardInstanceStatutType.REFUSEE
) { ) {
throw new BadRequestException('Carte non modifiable dans cet état'); throw new BadRequestException('Carte non modifiable dans cet état');
} }
}
const debut = const debut =
dto.date_debut ?? String(card.payload?.['date_debut'] ?? ''); dto.date_debut ?? String(card.payload?.['date_debut'] ?? '');
@@ -294,21 +351,79 @@ export class CardsService {
throw new BadRequestException('Dates invalides'); throw new BadRequestException('Dates invalides');
} }
let motif = dto.motif ?? card.payload?.['motif'] ?? null;
let finalPayload = {
...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) { if (card.id_evenement) {
await this.evenementsService.maj(userId, role, card.id_evenement, { await this.evenementsService.maj(userId, role, card.id_evenement, {
date_debut: debut, date_debut: debut,
date_fin: fin, date_fin: fin,
motif: dto.motif, motif: motif ?? undefined,
statut: StatutEvenementAgendaType.EN_ATTENTE, ...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}),
}); });
} }
card.payload = { card.payload = finalPayload;
...card.payload,
date_debut: debut,
date_fin: fin,
motif: dto.motif ?? card.payload?.['motif'] ?? null,
};
card.statut = CardInstanceStatutType.OUVERTE; card.statut = CardInstanceStatutType.OUVERTE;
card.purge_at = this.purgeAt(card.type.retention_days, card.statut); card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.manager.query( await this.cardsRepo.manager.query(
@@ -332,16 +447,25 @@ export class CardsService {
if (card.cree_par !== userId) { if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); 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); const audienceIds = await this.audienceUserIds(card);
if (card.id_evenement) { if (card.id_evenement) {
const absStatut = const absStatut =
card.statut === CardInstanceStatutType.TRAITEE card.statut === CardInstanceStatutType.TRAITEE
? null ? null
: card.id_evenement; : 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 ( if (
card.statut === CardInstanceStatutType.OUVERTE || 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); await this.evenementsService.supprimer(userId, role, card.id_evenement);
} }
@@ -379,6 +503,9 @@ export class CardsService {
if (typeCode === 'arret_maladie_am') { if (typeCode === 'arret_maladie_am') {
return TypeEvenementAgendaType.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}`); throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`);
} }
@@ -48,6 +48,10 @@ export class CreerCarteDto {
@IsOptional() @IsOptional()
@IsEnum(CardOperationType) @IsEnum(CardOperationType)
operation?: CardOperationType; operation?: CardOperationType;
@ApiPropertyOptional()
@IsOptional()
payload?: Record<string, any>;
} }
export class RepondreCarteDto { export class RepondreCarteDto {
@@ -79,6 +83,10 @@ export class MajCarteDto {
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
motif?: string; motif?: string;
@ApiPropertyOptional()
@IsOptional()
payload?: Record<string, any>;
} }
export class CarteDto { export class CarteDto {
@@ -196,7 +196,7 @@ export class EvenementsAgendaService {
} }
private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType { private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType {
if (type === TypeEvenementAgendaType.ABSENCE_ENFANT) { if (type === TypeEvenementAgendaType.ABSENCE_ENFANT || type === TypeEvenementAgendaType.SOIN_ENFANT) {
return StatutEvenementAgendaType.ACCEPTE; return StatutEvenementAgendaType.ACCEPTE;
} }
return StatutEvenementAgendaType.EN_ATTENTE; return StatutEvenementAgendaType.EN_ATTENTE;
@@ -233,10 +233,11 @@ export class EvenementsAgendaService {
if (role === RoleType.ASSISTANTE_MATERNELLE) { if (role === RoleType.ASSISTANTE_MATERNELLE) {
if ( if (
type !== TypeEvenementAgendaType.CONGE_AM && type !== TypeEvenementAgendaType.CONGE_AM &&
type !== TypeEvenementAgendaType.ARRET_MALADIE_AM type !== TypeEvenementAgendaType.ARRET_MALADIE_AM &&
type !== TypeEvenementAgendaType.SOIN_ENFANT
) { ) {
throw new ForbiddenException( 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; return;
+1 -1
View File
@@ -37,7 +37,7 @@ DO $$ BEGIN
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN
CREATE TYPE type_evenement_agenda_type AS ENUM ( 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; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN
@@ -108,6 +108,11 @@ INSERT INTO card_types (
'arret_maladie_am', true, 'Arrêt maladie AM', 'arret_maladie_am', true, 'Arrêt maladie AM',
ARRAY['assistante_maternelle'], ARRAY['parent'], ARRAY['assistante_maternelle'], ARRAY['parent'],
'couple_parents', 'ack', 14, 'pink' '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 ON CONFLICT (code) DO UPDATE SET
titre = EXCLUDED.titre, titre = EXCLUDED.titre,
@@ -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';
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+54 -1
View File
@@ -13,6 +13,7 @@ enum CarteStatut {
switch (raw) { switch (raw) {
case 'refusee': case 'refusee':
return CarteStatut.refusee; return CarteStatut.refusee;
case 'fermee':
case 'traitee': case 'traitee':
return CarteStatut.traitee; return CarteStatut.traitee;
case 'ouverte': case 'ouverte':
@@ -80,10 +81,42 @@ class CarteBulle {
this.modifieLe, this.modifieLe,
}); });
CarteBulle copyWith({
CarteStatut? statut,
bool? isCreator,
String? lastRefuseComment,
DateTime? modifieLe,
Map<String, dynamic>? payload,
}) {
return CarteBulle(
id: id,
typeCode: typeCode,
titre: titre,
idPlacement: idPlacement,
idAbsence: idAbsence,
operation: operation,
statut: statut ?? this.statut,
payload: payload ?? this.payload,
purgeAt: purgeAt,
creePar: creePar,
isCreator: isCreator ?? this.isCreator,
responseMode: responseMode,
lastRefuseComment: lastRefuseComment ?? this.lastRefuseComment,
creeLe: creeLe,
modifieLe: modifieLe ?? this.modifieLe,
);
}
String? get dateDebut => payload['date_debut']?.toString(); String? get dateDebut => payload['date_debut']?.toString();
String? get dateFin => payload['date_fin']?.toString(); String? get dateFin => payload['date_fin']?.toString();
String? get motif => payload['motif']?.toString(); String? get motif => payload['motif']?.toString();
/// Helper pour extraire les informations de soin
String? get resumerSoin {
if (!isSoinEnfant) return null;
return payload['motif']?.toString();
}
/// Motif de refus : réponse API, sinon motif payload (co-parent / reload). /// Motif de refus : réponse API, sinon motif payload (co-parent / reload).
String? get motifRefus { String? get motifRefus {
final fromResponse = lastRefuseComment?.trim(); final fromResponse = lastRefuseComment?.trim();
@@ -113,6 +146,21 @@ class CarteBulle {
bool get isArretMaladie => typeCode == 'arret_maladie_am'; bool get isArretMaladie => typeCode == 'arret_maladie_am';
bool get isCongeAm => typeCode == 'conge_am'; bool get isCongeAm => typeCode == 'conge_am';
bool get isSoinEnfant => typeCode == 'soin_enfant'; 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. /// Icône dessin (haut droite) selon le type de bulle.
String? get iconAsset { String? get iconAsset {
@@ -124,7 +172,12 @@ class CarteBulle {
case 'absence_enfant': case 'absence_enfant':
case 'absence_enfant_modif': case 'absence_enfant_modif':
return 'assets/images/sac_lapin.png'; 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_croix.png';
if (st == 'medicament') return 'assets/images/cuillere.png'; // Using generic fallback or specific if added later. Assuming thermo_croix or similar could work.
if (st == 'bobo') return 'assets/images/sac_lapin.png'; // Will adjust these later if better assets exist. Let's use generic ones for now, or null if no appropriate assets.
return null;
default: default:
return null; return null;
} }
+106
View File
@@ -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<CarteSousTypeUi> 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 = <String, CarteTypeUi>{
'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<String, dynamic> 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;
}
@@ -3,14 +3,15 @@ import 'package:p_tits_pas/models/couple_garde.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/couple_garde_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/couple_selector_bandeau.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.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/widgets/quotidien/quotidien_theme.dart';
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'; import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170). /// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
/// Métier cartes / blog / messagerie : tickets C/D/E. /// Création « Ajouter une carte » : modale générique (#175, réutilisée #176).
class AmDashboardScreen extends StatefulWidget { class AmDashboardScreen extends StatefulWidget {
const AmDashboardScreen({super.key}); const AmDashboardScreen({super.key});
@@ -138,6 +139,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
} }
} }
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174 / #175).
class _LeftColumn extends StatelessWidget { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -172,12 +174,9 @@ class _LeftColumn extends StatelessWidget {
onCoupleSelected: onCoupleSelected, onCoupleSelected: onCoupleSelected,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Expanded( Expanded(
child: QuotidienColumnPlaceholder( child: CartesFeed(
title: 'Cartes', placementId: selectedCoupleId,
subtitle:
'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).',
icon: Icons.style_outlined,
), ),
), ),
], ],
@@ -115,7 +115,6 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
error: _couplesError, error: _couplesError,
onRetry: _loadCouples, onRetry: _loadCouples,
onCoupleSelected: _selectCouple, onCoupleSelected: _selectCouple,
onAjouterBulle: () => _soon('Ajouter une bulle'),
), ),
centerColumn: const QuotidienColumnPlaceholder( centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog', title: 'Blog',
@@ -138,7 +137,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
} }
} }
/// 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 { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -146,7 +145,6 @@ class _LeftColumn extends StatelessWidget {
final String? error; final String? error;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected; final ValueChanged<CoupleGarde> onCoupleSelected;
final VoidCallback onAjouterBulle;
const _LeftColumn({ const _LeftColumn({
required this.couples, required this.couples,
@@ -155,7 +153,6 @@ class _LeftColumn extends StatelessWidget {
required this.error, required this.error,
required this.onRetry, required this.onRetry,
required this.onCoupleSelected, required this.onCoupleSelected,
required this.onAjouterBulle,
}); });
@override @override
@@ -177,7 +174,6 @@ class _LeftColumn extends StatelessWidget {
Expanded( Expanded(
child: CartesFeed( child: CartesFeed(
placementId: selectedCoupleId, placementId: selectedCoupleId,
onAjouterBulle: onAjouterBulle,
), ),
), ),
], ],
+104 -2
View File
@@ -2,11 +2,14 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/carte_bulle.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/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.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?placementId=
/// - GET /cards/types
/// - POST /cards
/// - POST /cards/:id/respond /// - POST /cards/:id/respond
class CardsService { class CardsService {
static Future<Map<String, String>> _headers() async { static Future<Map<String, String>> _headers() async {
@@ -57,6 +60,105 @@ class CardsService {
.toList(); .toList();
} }
/// Types SYSTEM émissibles pour le rôle connecté.
static Future<List<CarteTypeEmissible>> 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>()
.map((e) => CarteTypeEmissible.fromJson(Map<String, dynamic>.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<CarteBulle> creer({
required String typeCode,
required String idPlacement,
required String dateDebut,
required String dateFin,
String? motif,
Map<String, dynamic>? 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<String, dynamic>.from(decoded as Map));
}
/// Modifier une carte (corriger un soin, dates en attente...).
static Future<CarteBulle> maj({
required String cardId,
String? dateDebut,
String? dateFin,
String? motif,
Map<String, dynamic>? 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<String, dynamic>.from(decoded as Map));
}
/// Supprimer une bulle (AM 24h ou avant traitement).
static Future<void> 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. /// Répondre : accept | refuse | ack.
static Future<CarteBulle> repondre({ static Future<CarteBulle> repondre({
required String cardId, required String cardId,
@@ -75,7 +177,7 @@ class CardsService {
}), }),
); );
if (response.statusCode != 200) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception( throw Exception(
_extractError(response.body, 'Erreur lors de la réponse'), _extractError(response.body, 'Erreur lors de la réponse'),
); );
@@ -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<CarteBulle?> showAjouterCarteModal(
BuildContext context, {
required String placementId,
CarteBulle? carteToEdit,
}) {
return showDialog<CarteBulle>(
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<AjouterCarteModal> createState() => _AjouterCarteModalState();
}
class _AjouterCarteModalState extends State<AjouterCarteModal> {
List<CarteTypeEmissible> _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<CarteTypeEmissible> 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<void> _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<void> _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<void> _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<void> _pickDateRange() async {
final initialEnd =
_dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin;
final picked = await showDialog<DateTimeRange>(
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<String, dynamic>? _buildPayload() {
if (_selected?.code != 'soin_enfant') {
return null;
}
final base = <String, dynamic>{
'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<void> _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<String>(
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<String>(
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),
),
),
);
}
}
+245 -55
View File
@@ -1,16 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/card_assets.dart'; import 'package:p_tits_pas/models/card_assets.dart';
import 'package:p_tits_pas/models/carte_bulle.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_service.dart';
import 'package:p_tits_pas/services/api/cards_stream_client.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/carte.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Feed de bulles (file d'attention) — ticket #173. /// Feed de bulles (file d'attention) — tickets #173 (parent) / #174 (AM).
/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API. /// Widget partagé ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget { class CartesFeed extends StatefulWidget {
final String? placementId; final String? placementId;
/// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal].
final VoidCallback? onAjouterBulle; final VoidCallback? onAjouterBulle;
const CartesFeed({ const CartesFeed({
@@ -37,15 +43,23 @@ class _CartesFeedState extends State<CartesFeed> {
bool _fadeBottom = false; bool _fadeBottom = false;
CardsStreamClient? _stream; CardsStreamClient? _stream;
String? _userId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_syncScrollFades); _scrollController.addListener(_syncScrollFades);
unawaited(_loadUserId());
_load(); _load();
_startStream(); _startStream();
} }
Future<void> _loadUserId() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
_userId = user?.id;
}
@override @override
void dispose() { void dispose() {
_stream?.stop(); _stream?.stop();
@@ -73,9 +87,25 @@ class _CartesFeedState extends State<CartesFeed> {
)..start(); )..start();
} }
/// SSE diffuse le DTO du répondant / créateur : recalcule `is_creator` local.
CarteBulle _avecIsCreatorLocal(CarteBulle incoming, {CarteBulle? previous}) {
if (previous != null) {
return incoming.copyWith(isCreator: previous.isCreator);
}
final uid = _userId;
if (uid != null &&
incoming.creePar != null &&
incoming.creePar!.isNotEmpty) {
return incoming.copyWith(isCreator: incoming.creePar == uid);
}
return incoming;
}
void _onStreamEvent(CardsStreamEvent event) { void _onStreamEvent(CardsStreamEvent event) {
if (!mounted) return; if (!mounted) return;
if (event.type == 'heartbeat') return; if (event.type == 'heartbeat') return;
// `response.added` puis `card.updated` : on ne traite que l’update carte.
if (event.type == 'response.added') return;
if (event.type == 'card.deleted') { if (event.type == 'card.deleted') {
final id = event.cardId; final id = event.cardId;
@@ -90,27 +120,37 @@ class _CartesFeedState extends State<CartesFeed> {
return; return;
} }
final card = event.card; final raw = event.card;
if (card == null) return; if (raw == null) return;
final filter = widget.placementId; final filter = widget.placementId;
final matchesFilter = filter == null || final matchesFilter = filter == null ||
filter.isEmpty || filter.isEmpty ||
card.idPlacement == filter; raw.idPlacement == filter;
setState(() { setState(() {
if (!matchesFilter) { if (!matchesFilter) {
_items = _trier([ _items = _trier([
for (final c in _items) for (final c in _items)
if (c.id != card.id) c, if (c.id != raw.id) c,
]); ]);
} else if (_items.any((c) => c.id == card.id)) { } else {
CarteBulle? previous;
for (final c in _items) {
if (c.id == raw.id) {
previous = c;
break;
}
}
final card = _avecIsCreatorLocal(raw, previous: previous);
if (previous != null) {
_items = _trier([ _items = _trier([
for (final c in _items) if (c.id == card.id) card else c, for (final c in _items) if (c.id == card.id) card else c,
]); ]);
} else { } else {
_items = _trier([..._items, card]); _items = _trier([..._items, card]);
} }
}
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
} }
@@ -134,28 +174,30 @@ class _CartesFeedState extends State<CartesFeed> {
} }
} }
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis /// Tri stable partagé : refusées → encore actives (ouvertes / infos
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents). /// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
static List<CarteBulle> _trier(List<CarteBulle> items) { static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) { int rang(CarteBulle c) {
switch (s) { switch (c.statut) {
case CarteStatut.refusee: case CarteStatut.refusee:
return 0; return 0;
case CarteStatut.ouverte: case CarteStatut.ouverte:
return 1; return 1;
case CarteStatut.traitee: 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<CarteBulle>.from(items); final out = List<CarteBulle>.from(items);
out.sort((a, b) { 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; if (byStatut != 0) return byStatut;
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0; final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0; final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
final byModif = bm.compareTo(am); // DESC final byCree = bc.compareTo(ac); // DESC
if (byModif != 0) return byModif; if (byCree != 0) return byCree;
return a.id.compareTo(b.id); return a.id.compareTo(b.id);
}); });
return out; return out;
@@ -334,23 +376,42 @@ class _CartesFeedState extends State<CartesFeed> {
color: QuotidienTheme.ink, 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), const SizedBox(height: 8),
Expanded(child: _buildBody()), Expanded(child: _buildBody()),
_AjouterCarteCta(onTap: _onAjouterCarte),
], ],
); );
} }
Future<void> _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() { Widget _buildBody() {
if (_loading) { if (_loading) {
return const Center( return const Center(
@@ -439,7 +500,7 @@ class _CartesFeedState extends State<CartesFeed> {
onRefresh: _load, onRefresh: _load,
child: ListView.separated( child: ListView.separated(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 8), padding: const EdgeInsets.only(top: 4, bottom: 4),
itemCount: _items.length, itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -550,8 +611,15 @@ class _BulleTile extends StatelessWidget {
return '$jj ${_mois[dt.month - 1]} ${dt.year}'; return '$jj ${_mois[dt.month - 1]} ${dt.year}';
} }
/// Une ligne type maquette : « Du 04 au 06 novembre 2026 ».
String _fmtPeriodeUneLigne() { String _fmtPeriodeUneLigne() {
// Si c'est un soin_enfant en singleDay, on affiche juste "Le ..." ou un résumé si pertinent
if (card.isSoinEnfant) {
final resume = card.resumerSoin;
final jour = card.dateDebut != null ? 'Le ${_fmtLong(card.dateDebut)}' : '';
if (resume != null) return '$jour\n$resume';
return jour;
}
final d1 = DateTime.tryParse(card.dateDebut ?? ''); final d1 = DateTime.tryParse(card.dateDebut ?? '');
final d2 = DateTime.tryParse(card.dateFin ?? ''); final d2 = DateTime.tryParse(card.dateFin ?? '');
if (d1 == null && d2 == null) return ''; if (d1 == null && d2 == null) return '';
@@ -574,26 +642,34 @@ class _BulleTile extends StatelessWidget {
'au $j2 ${_mois[b.month - 1]} ${b.year}'; '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 { bool get _attenuee {
if (card.isArretMaladie && card.periodeEncoreActive) return false; if (card.restePleineDansFile) return false;
return card.statut == CarteStatut.traitee; return card.statut == CarteStatut.traitee;
} }
/// Congés (Validée / Refusée) ou carte ack (Bien reçu). /// Statut bas de carte (centré) : Validée / Refusée / En attente / Vu / Bien reçu.
bool get _montreStatutReponse { bool get _montreStatutReponse {
if (card.peutRepondre) return false; if (card.peutRepondre) return false;
if (card.isCongeAm && if (card.isCongeAm) {
(card.statut == CarteStatut.traitee || if (card.statut == CarteStatut.ouverte) return card.isCreator;
card.statut == CarteStatut.refusee)) { return card.statut == CarteStatut.traitee ||
return true; card.statut == CarteStatut.refusee;
} }
// Arrêt (ack) : AM créateur → « Vu » seulement après ack parent.
// Destinataire → « Bien reçu » une fois traité.
return card.responseMode == CarteResponseMode.ack && return card.responseMode == CarteResponseMode.ack &&
card.statut == CarteStatut.traitee; card.statut == CarteStatut.traitee;
} }
String get _libelleStatutReponse { String get _libelleStatutReponse {
if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu'; if (card.responseMode == CarteResponseMode.ack) {
return card.isCreator ? 'Vu' : 'Bien reçu';
}
if (card.isCongeAm && card.statut == CarteStatut.ouverte) {
return 'En attente de validation';
}
// Accordé à « Demande de congés ». // Accordé à « Demande de congés ».
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée'; return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
} }
@@ -675,20 +751,6 @@ class _BulleTile extends StatelessWidget {
color: encre.ink, 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,
),
),
], ],
], ],
), ),
@@ -747,7 +809,7 @@ class _BulleTile extends StatelessWidget {
_libelleStatutReponse, _libelleStatutReponse,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
color: encre.ink.withValues(alpha: 0.75), color: encre.ink.withValues(alpha: 0.75),
@@ -795,6 +857,11 @@ class _BulleTile extends StatelessWidget {
} }
Widget _buildGeneric(BuildContext context) { 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 && final memeJour = card.dateDebut != null &&
card.dateFin != null && card.dateFin != null &&
card.dateDebut == card.dateFin; card.dateDebut == card.dateFin;
@@ -815,7 +882,10 @@ class _BulleTile extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Expanded(
child: Text(
card.titreAffiche, card.titreAffiche,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -825,13 +895,28 @@ class _BulleTile extends StatelessWidget {
color: QuotidienTheme.ink, color: QuotidienTheme.ink,
), ),
), ),
if (card.dateDebut != null || card.dateFin != null) ...[ ),
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 || card.isSoinEnfant) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Padding( Padding(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
child: memeJour child: (memeJour || card.isSoinEnfant)
? Text( ? Text(
'Le ${_fmtLong(card.dateDebut)}', _fmtPeriodeUneLigne(),
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -905,6 +990,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<bool>(
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) ...[ if (card.peutRepondre) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
if (busy) if (busy)
@@ -1026,6 +1177,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. /// Bouton pilule peinte (coche / croix intégrées) — carte congés.
class _PilulePeinteButton extends StatelessWidget { class _PilulePeinteButton extends StatelessWidget {
final String asset; final String asset;