Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb35075614 | ||
|
|
19b5b9e19c | ||
|
|
8af579be53 | ||
|
|
a36e9ef176 | ||
|
|
5d36b0f4c1 |
@@ -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é
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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':
|
||||||
@@ -110,6 +111,12 @@ class CarteBulle {
|
|||||||
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();
|
||||||
@@ -139,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 {
|
||||||
@@ -150,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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).
|
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
|
||||||
/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
|
/// 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 {
|
class AmDashboardScreen extends StatefulWidget {
|
||||||
const AmDashboardScreen({super.key});
|
const AmDashboardScreen({super.key});
|
||||||
|
|
||||||
@@ -117,7 +117,6 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
|||||||
error: _couplesError,
|
error: _couplesError,
|
||||||
onRetry: _loadCouples,
|
onRetry: _loadCouples,
|
||||||
onCoupleSelected: _selectCouple,
|
onCoupleSelected: _selectCouple,
|
||||||
onAjouterBulle: () => _soon('Ajouter une bulle (#176)'),
|
|
||||||
),
|
),
|
||||||
centerColumn: const QuotidienColumnPlaceholder(
|
centerColumn: const QuotidienColumnPlaceholder(
|
||||||
title: 'Blog',
|
title: 'Blog',
|
||||||
@@ -140,7 +139,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
class _LeftColumn extends StatelessWidget {
|
||||||
final List<CoupleGarde> couples;
|
final List<CoupleGarde> couples;
|
||||||
final String? selectedCoupleId;
|
final String? selectedCoupleId;
|
||||||
@@ -148,7 +147,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,
|
||||||
@@ -157,7 +155,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
|
||||||
@@ -180,7 +177,6 @@ class _LeftColumn extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: CartesFeed(
|
child: CartesFeed(
|
||||||
placementId: selectedCoupleId,
|
placementId: selectedCoupleId,
|
||||||
onAjouterBulle: onAjouterBulle,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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/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';
|
||||||
|
|
||||||
@@ -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.
|
/// 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({
|
||||||
@@ -171,24 +174,25 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tri stable partagé : refusées → ouvertes → traitées, puis
|
/// Tri stable partagé : refusées → encore actives (ouvertes / infos
|
||||||
/// `cree_le` DESC + `id` (pas `modifie_le` : une validation ne
|
/// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
|
||||||
/// re-mélange plus la file différemment selon les clients).
|
|
||||||
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 ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
|
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||||
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
|
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||||
@@ -372,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(
|
||||||
@@ -477,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) {
|
||||||
@@ -588,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 '';
|
||||||
@@ -612,9 +642,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,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;
|
||||||
@@ -846,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,
|
||||||
@@ -856,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,
|
||||||
@@ -936,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)
|
||||||
@@ -1057,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user