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
7 changed files with 525 additions and 83 deletions
+40 -40
View File
@@ -137,7 +137,7 @@ export class CardsService {
const absenceType = this.mapAbsenceType(dto.type_code); const absenceType = this.mapAbsenceType(dto.type_code);
let motif = dto.motif ?? null; let motif = dto.motif ?? null;
let finalPayload: Record<string, any> = { let finalPayload = {
date_debut: dto.date_debut, date_debut: dto.date_debut,
date_fin: dto.date_fin, date_fin: dto.date_fin,
motif, motif,
@@ -145,40 +145,40 @@ export class CardsService {
}; };
if (dto.type_code === 'soin_enfant') { if (dto.type_code === 'soin_enfant') {
const p = dto.payload as Record<string, any>; const p = dto.payload;
if (!p || !p?.sous_type || !p?.heure_releve) { if (!p || !p.sous_type || !p.heure_releve) {
throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant'); 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)) { if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p.heure_releve)) {
throw new BadRequestException('Format heure_releve invalide (HH:mm)'); throw new BadRequestException('Format heure_releve invalide (HH:mm)');
} }
if (p?.sous_type === 'fievre') { if (p.sous_type === 'fievre') {
if (typeof p?.temperature_c !== 'number' || p?.temperature_c < 34.0 || p?.temperature_c > 43.0) { 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)'); throw new BadRequestException('temperature_c invalide (34.0 - 43.0)');
} }
motif = `${p?.temperature_c.toFixed(1).replace('.', ',')} °C à ${p?.heure_releve}`; motif = `${p.temperature_c.toFixed(1).replace('.', ',')} °C à ${p.heure_releve}`;
if (p?.medicament_donne && typeof p?.medicament_donne === 'string' && p?.medicament_donne.trim()) { if (p.medicament_donne && typeof p.medicament_donne === 'string' && p.medicament_donne.trim()) {
motif += ` (Médicament : ${p?.medicament_donne.trim()})`; motif += ` (Médicament : ${p.medicament_donne.trim()})`;
} }
} else if (p?.sous_type === 'bobo') { } else if (p.sous_type === 'bobo') {
const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre']; const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre'];
if (!natures.includes(p?.bobo_nature)) { if (!natures.includes(p.bobo_nature)) {
throw new BadRequestException('bobo_nature invalide'); throw new BadRequestException('bobo_nature invalide');
} }
motif = `Bobo (${p?.bobo_nature}) à ${p?.heure_releve}`; motif = `Bobo (${p.bobo_nature}) à ${p.heure_releve}`;
if (p?.description && typeof p?.description === 'string' && p?.description.trim()) { if (p.description && typeof p.description === 'string' && p.description.trim()) {
const desc = p?.description.trim(); const desc = p.description.trim();
if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)'); if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)');
motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`; motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`;
} }
} else if (p?.sous_type === 'medicament') { } else if (p.sous_type === 'medicament') {
if (!p?.medicament_nom || typeof p?.medicament_nom !== 'string' || !p?.medicament_nom.trim()) { if (!p.medicament_nom || typeof p.medicament_nom !== 'string' || !p.medicament_nom.trim()) {
throw new BadRequestException('medicament_nom requis'); throw new BadRequestException('medicament_nom requis');
} }
motif = `Médicament (${p?.medicament_nom.trim()}) à ${p?.heure_releve}`; motif = `Médicament (${p.medicament_nom.trim()}) à ${p.heure_releve}`;
if (p?.contexte && typeof p?.contexte === 'string' && p?.contexte.trim()) { if (p.contexte && typeof p.contexte === 'string' && p.contexte.trim()) {
if (p?.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)'); if (p.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)');
} }
} else { } else {
throw new BadRequestException('sous_type invalide pour soin_enfant'); throw new BadRequestException('sous_type invalide pour soin_enfant');
@@ -192,7 +192,7 @@ export class CardsService {
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: motif ? String(motif) : undefined, motif: motif ?? undefined,
}); });
absenceId = absence.id; absenceId = absence.id;
} else { } else {
@@ -205,7 +205,7 @@ export class CardsService {
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: motif ? String(motif) : undefined, 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'
@@ -352,7 +352,7 @@ export class CardsService {
} }
let motif = dto.motif ?? card.payload?.['motif'] ?? null; let motif = dto.motif ?? card.payload?.['motif'] ?? null;
let finalPayload: Record<string, any> = { let finalPayload = {
...card.payload, ...card.payload,
date_debut: debut, date_debut: debut,
date_fin: fin, date_fin: fin,
@@ -361,39 +361,39 @@ export class CardsService {
if (card.type_code === 'soin_enfant') { if (card.type_code === 'soin_enfant') {
const p = finalPayload; const p = finalPayload;
if (!p || !p?.sous_type || !p?.heure_releve) { if (!p || !p.sous_type || !p.heure_releve) {
throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant'); 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)) { if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p.heure_releve)) {
throw new BadRequestException('Format heure_releve invalide (HH:mm)'); throw new BadRequestException('Format heure_releve invalide (HH:mm)');
} }
if (p?.sous_type === 'fievre') { if (p.sous_type === 'fievre') {
if (typeof p?.temperature_c !== 'number' || p?.temperature_c < 34.0 || p?.temperature_c > 43.0) { 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)'); throw new BadRequestException('temperature_c invalide (34.0 - 43.0)');
} }
motif = `${p?.temperature_c.toFixed(1).replace('.', ',')} °C à ${p?.heure_releve}`; motif = `${p.temperature_c.toFixed(1).replace('.', ',')} °C à ${p.heure_releve}`;
if (p?.medicament_donne && typeof p?.medicament_donne === 'string' && p?.medicament_donne.trim()) { if (p.medicament_donne && typeof p.medicament_donne === 'string' && p.medicament_donne.trim()) {
motif += ` (Médicament : ${p?.medicament_donne.trim()})`; motif += ` (Médicament : ${p.medicament_donne.trim()})`;
} }
} else if (p?.sous_type === 'bobo') { } else if (p.sous_type === 'bobo') {
const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre']; const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre'];
if (!natures.includes(p?.bobo_nature)) { if (!natures.includes(p.bobo_nature)) {
throw new BadRequestException('bobo_nature invalide'); throw new BadRequestException('bobo_nature invalide');
} }
motif = `Bobo (${p?.bobo_nature}) à ${p?.heure_releve}`; motif = `Bobo (${p.bobo_nature}) à ${p.heure_releve}`;
if (p?.description && typeof p?.description === 'string' && p?.description.trim()) { if (p.description && typeof p.description === 'string' && p.description.trim()) {
const desc = p?.description.trim(); const desc = p.description.trim();
if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)'); if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)');
motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`; motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`;
} }
} else if (p?.sous_type === 'medicament') { } else if (p.sous_type === 'medicament') {
if (!p?.medicament_nom || typeof p?.medicament_nom !== 'string' || !p?.medicament_nom.trim()) { if (!p.medicament_nom || typeof p.medicament_nom !== 'string' || !p.medicament_nom.trim()) {
throw new BadRequestException('medicament_nom requis'); throw new BadRequestException('medicament_nom requis');
} }
motif = `Médicament (${p?.medicament_nom.trim()}) à ${p?.heure_releve}`; motif = `Médicament (${p.medicament_nom.trim()}) à ${p.heure_releve}`;
if (p?.contexte && typeof p?.contexte === 'string' && p?.contexte.trim()) { if (p.contexte && typeof p.contexte === 'string' && p.contexte.trim()) {
if (p?.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)'); if (p.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)');
} }
} else { } else {
throw new BadRequestException('sous_type invalide pour soin_enfant'); throw new BadRequestException('sous_type invalide pour soin_enfant');
@@ -418,7 +418,7 @@ export class CardsService {
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: motif ? String(motif) : undefined, motif: motif ?? undefined,
...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}), ...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}),
}); });
} }
@@ -3,7 +3,6 @@ import {
IsDateString, IsDateString,
IsEnum, IsEnum,
IsOptional, IsOptional,
IsObject,
IsString, IsString,
IsUUID, IsUUID,
MaxLength, MaxLength,
@@ -52,7 +51,6 @@ export class CreerCarteDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsObject()
payload?: Record<string, any>; payload?: Record<string, any>;
} }
@@ -88,7 +86,6 @@ export class MajCarteDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsObject()
payload?: Record<string, any>; payload?: Record<string, any>;
} }
+13 -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':
@@ -109,6 +110,12 @@ class CarteBulle {
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 {
@@ -165,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;
} }
+1 -5
View File
@@ -49,18 +49,14 @@ class CarteTypeUi {
label: 'Arrêt maladie', label: 'Arrêt maladie',
motifHint: 'Précisions (optionnel)', motifHint: 'Précisions (optionnel)',
), ),
// Point d’extension #soins — visible dès que le back l’émet.
'soin_enfant': CarteTypeUi( 'soin_enfant': CarteTypeUi(
typeCode: 'soin_enfant', typeCode: 'soin_enfant',
label: 'Soin / bobo', label: 'Soin / bobo',
motifHint: 'Décrivez le soin ou le bobo',
motifRequired: true,
periodeMode: CartePeriodeMode.singleDay, periodeMode: CartePeriodeMode.singleDay,
sousTypes: [ sousTypes: [
CarteSousTypeUi(code: 'temperature', label: 'Température'), CarteSousTypeUi(code: 'fievre', label: 'Fièvre'),
CarteSousTypeUi(code: 'bobo', label: 'Bobo'), CarteSousTypeUi(code: 'bobo', label: 'Bobo'),
CarteSousTypeUi(code: 'medicament', label: 'Médicament'), CarteSousTypeUi(code: 'medicament', label: 'Médicament'),
CarteSousTypeUi(code: 'autre', label: 'Autre'),
], ],
), ),
}; };
@@ -91,6 +91,7 @@ class CardsService {
required String dateDebut, required String dateDebut,
required String dateFin, required String dateFin,
String? motif, String? motif,
Map<String, dynamic>? payload,
}) async { }) async {
final response = await http.post( final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}'),
@@ -101,6 +102,7 @@ class CardsService {
'date_debut': dateDebut, 'date_debut': dateDebut,
'date_fin': dateFin, 'date_fin': dateFin,
if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(), if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(),
if (payload != null) 'payload': payload,
}), }),
); );
@@ -114,6 +116,49 @@ class CardsService {
return CarteBulle.fromJson(Map<String, dynamic>.from(decoded as Map)); 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,
@@ -10,19 +10,28 @@ import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
Future<CarteBulle?> showAjouterCarteModal( Future<CarteBulle?> showAjouterCarteModal(
BuildContext context, { BuildContext context, {
required String placementId, required String placementId,
CarteBulle? carteToEdit,
}) { }) {
return showDialog<CarteBulle>( return showDialog<CarteBulle>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => AjouterCarteModal(placementId: placementId), builder: (ctx) => AjouterCarteModal(
placementId: placementId,
carteToEdit: carteToEdit,
),
); );
} }
/// Modale générique : types API + catalogue UI, période (pas de motif V1). /// Modale générique : types API + catalogue UI, période, soins
class AjouterCarteModal extends StatefulWidget { class AjouterCarteModal extends StatefulWidget {
final String placementId; final String placementId;
final CarteBulle? carteToEdit;
const AjouterCarteModal({super.key, required this.placementId}); const AjouterCarteModal({
super.key,
required this.placementId,
this.carteToEdit,
});
@override @override
State<AjouterCarteModal> createState() => _AjouterCarteModalState(); State<AjouterCarteModal> createState() => _AjouterCarteModalState();
@@ -38,6 +47,17 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
bool _submitting = false; bool _submitting = false;
String? _error; 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 => CarteTypeUi get _ui =>
_selected?.ui ?? _selected?.ui ??
CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu'); CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu');
@@ -51,6 +71,67 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_loadTypes(); _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 { Future<void> _loadTypes() async {
setState(() { setState(() {
_loadingTypes = true; _loadingTypes = true;
@@ -66,6 +147,8 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_loadingTypes = false; _loadingTypes = false;
if (types.isEmpty) { if (types.isEmpty) {
_error = 'Aucun type de carte disponible pour votre rôle.'; _error = 'Aucun type de carte disponible pour votre rôle.';
} else {
_prefillIfEdit(types);
} }
}); });
} catch (e) { } catch (e) {
@@ -90,6 +173,22 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
return '$day/$m/${d.year}'; 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 { Future<void> _pickSingleDay() async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@@ -180,11 +279,63 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
return null; return null;
} }
/// V1 : pas de texte libre ; seul un sous-type catalogue peut alimenter motif. /// V1 : pas de texte libre pour le motif si la logique change.
String? _motifOptionnel() => _sousTypeLabel(); /// (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 { Future<void> _submit() async {
if (_selected == null) return; 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)) { if (_dateFin.isBefore(_dateDebut)) {
setState(() => _error = 'La date de fin doit être après la date de début.'); setState(() => _error = 'La date de fin doit être après la date de début.');
return; return;
@@ -195,17 +346,36 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_error = null; _error = null;
}); });
try { try {
final created = await CardsService.creer( final payload = _buildPayload();
typeCode: _selected!.code,
idPlacement: widget.placementId, final dynamic created;
dateDebut: _fmtIso(_dateDebut), if (widget.carteToEdit != null) {
dateFin: _fmtIso( created = await CardsService.maj(
_ui.periodeMode == CartePeriodeMode.singleDay cardId: widget.carteToEdit!.id,
? _dateDebut dateDebut: _fmtIso(_dateDebut),
: _dateFin, dateFin: _fmtIso(
), _ui.periodeMode == CartePeriodeMode.singleDay
motif: _motifOptionnel(), ? _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; if (!mounted) return;
Navigator.of(context).pop(created); Navigator.of(context).pop(created);
} catch (e) { } catch (e) {
@@ -255,10 +425,27 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_buildTypeSelector(), _buildTypeSelector(),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_ui.periodeMode == CartePeriodeMode.singleDay) if (_ui.periodeMode == CartePeriodeMode.singleDay)
_DateField( Row(
label: 'Date', children: [
value: _fmtUi(_dateDebut), Expanded(
onTap: _submitting ? null : _pickSingleDay, 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 else
_DateField( _DateField(
@@ -297,6 +484,26 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
], ],
), ),
], ],
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;
},
),
],
], ],
), ),
), ),
@@ -374,17 +581,106 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
}, },
); );
} }
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 { class _DateField extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final VoidCallback? onTap; final VoidCallback? onTap;
final IconData icon;
const _DateField({ const _DateField({
required this.label, required this.label,
required this.value, required this.value,
required this.onTap, required this.onTap,
this.icon = Icons.calendar_today_outlined,
}); });
@override @override
@@ -396,7 +692,7 @@ class _DateField extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), suffixIcon: Icon(icon, size: 18),
), ),
child: Text( child: Text(
value, value,
+109 -13
View File
@@ -611,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 '';
@@ -850,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;
@@ -870,23 +882,41 @@ class _BulleTile extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
card.titreAffiche, children: [
maxLines: 2, Expanded(
overflow: TextOverflow.ellipsis, child: Text(
style: GoogleFonts.merienda( card.titreAffiche,
fontSize: 18, maxLines: 2,
fontWeight: FontWeight.w700, overflow: TextOverflow.ellipsis,
color: QuotidienTheme.ink, style: GoogleFonts.merienda(
), fontSize: 18,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
),
if (isEdited)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'(Corrigée)',
style: GoogleFonts.merriweather(
fontSize: 11,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink.withOpacity(0.6),
),
),
),
],
), ),
if (card.dateDebut != null || card.dateFin != null) ...[ if (card.dateDebut != null || card.dateFin != null || card.isSoinEnfant) ...[
const SizedBox(height: 10), 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,
@@ -960,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)