diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md new file mode 100644 index 0000000..870ef51 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md @@ -0,0 +1,132 @@ +# #131 — Fiche AM éditable + affiliation enfants (note front → back) + +**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter** + +--- + +## 1. Comportement UI (front) + +Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) : + +| Onglet | Contenu | +|--------|---------| +| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut | +| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher | + +En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`. + +--- + +## 2. Endpoints consommés + +### Déjà existants (partiels) + +| Méthode | Route | Usage | +|---------|-------|-------| +| `GET` | `/api/v1/assistantes-maternelles` | Liste AM | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) | +| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) | +| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) | + +### À créer (recommandé — miroir parent #131 / #115) + +| Méthode | Route | Rôle | +|---------|-------|------| +| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) | +| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant | +| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) | + +Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté). + +--- + +## 3. Modèle de données affiliation AM ↔ enfant + +**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) : + +Proposition alignée parent : + +```sql +-- Piste : enfants_assistantes_maternelles +CREATE TABLE enfants_assistantes_maternelles ( + id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + PRIMARY KEY (id_am, id_enfant) +); +``` + +Réponse API attendue sur `GET /assistantes-maternelles/:id` : + +```json +{ + "user_id": "uuid-am", + "user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" }, + "approval_number": "AGR-2024-12345", + "residence_city": "Bezons", + "max_children": 4, + "places_available": 2, + "available": true, + "amChildren": [ + { + "child": { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "status": "actif", + "birth_date": "2023-02-15" + } + } + ] +} +``` + +Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`). + +--- + +## 4. Body `PATCH …/fiche` suggéré + +```json +{ + "nom": "MARTIN", + "prenom": "Claire", + "email": "claire@example.com", + "telephone": "0612345678", + "adresse": "5 place Bellecour", + "ville": "Lyon", + "code_postal": "69002", + "statut": "actif", + "approval_number": "AGR-2024-12345", + "residence_city": "Lyon", + "max_children": 4, + "places_available": 2, + "biography": "…", + "available": true +} +``` + +NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1). + +--- + +## 5. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets | +| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM | +| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée | +| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` | +| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` | +| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier | + +--- + +## 6. Références + +- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId` +- Ticket Gitea **#131**, **#115** +- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md` diff --git a/frontend/lib/models/assistante_maternelle_model.dart b/frontend/lib/models/assistante_maternelle_model.dart index 2d53fb0..9a8cb96 100644 --- a/frontend/lib/models/assistante_maternelle_model.dart +++ b/frontend/lib/models/assistante_maternelle_model.dart @@ -1,30 +1,102 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class AssistanteMaternelleModel { final AppUser user; final String? approvalNumber; + final String? nir; final String? residenceCity; final int? maxChildren; final int? placesAvailable; + final String? biography; + final bool? available; + final String? agreementDate; + final List children; AssistanteMaternelleModel({ required this.user, this.approvalNumber, + this.nir, this.residenceCity, this.maxChildren, this.placesAvailable, + this.biography, + this.available, + this.agreementDate, + this.children = const [], }); factory AssistanteMaternelleModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.from(root['user'] ?? root); + if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = root['numero_dossier']; + } final user = AppUser.fromJson(userJson); - + final children = _parseChildren(root); + return AssistanteMaternelleModel( user: user, - approvalNumber: json['numero_agrement'] as String?, - residenceCity: json['ville_residence'] as String?, - maxChildren: json['nb_max_enfants'] as int?, - placesAvailable: json['place_disponible'] as int?, + approvalNumber: _str( + root['approval_number'] ?? root['numero_agrement'], + ), + nir: _str(root['nir'] ?? root['nir_chiffre']), + residenceCity: _str( + root['residence_city'] ?? root['ville_residence'], + ), + maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']), + placesAvailable: _int( + root['places_available'] ?? root['place_disponible'], + ), + biography: _str(root['biography'] ?? root['biographie']), + available: root['available'] as bool? ?? root['disponible'] as bool?, + agreementDate: _dateString( + root['agreement_date'] ?? root['date_agrement'], + ), + children: children, ); } + + static String? _str(dynamic v) { + if (v == null) return null; + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + + static int? _int(dynamic v) { + if (v == null) return null; + if (v is int) return v; + return int.tryParse(v.toString()); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + return v.toString().split('T').first; + } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + void add(ParentChildSummary? child) { + if (child == null || child.id.isEmpty || seen.contains(child.id)) return; + seen.add(child.id); + children.add(child); + } + + final links = json['amChildren'] ?? + json['am_children'] ?? + json['assistanteChildren'] ?? + json['assistante_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 1d1c87f..3aa8111 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -513,7 +513,150 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); + return data + .map((e) => AssistanteMaternelleModel.fromJson( + Map.from(e as Map), + )) + .toList(); + } + + static Future getAssistanteMaternelle( + String userId, + ) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'), + headers: await _headers(), + ); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + if (response.statusCode == 403 || response.statusCode == 404) { + final all = await getAssistantesMaternelles(); + return all.firstWhere( + (a) => a.user.id == userId, + orElse: () => throw Exception('Assistante maternelle introuvable'), + ); + } + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM'); + } + + /// Mise à jour fiche AM (identité + champs pro). Ticket #131. + static Future updateAmFiche({ + required String amUserId, + required Map body, + }) async { + final ficheResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + if (ficheResponse.statusCode == 200) { + return _amModelFromBody(ficheResponse.body); + } + if (ficheResponse.statusCode != 404) { + throw Exception( + _extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'), + ); + } + + final userFields = {}; + for (final k in [ + 'nom', + 'prenom', + 'email', + 'telephone', + 'adresse', + 'ville', + 'code_postal', + 'statut', + ]) { + if (body.containsKey(k)) userFields[k] = body[k]; + } + + final proFields = {}; + for (final entry in body.entries) { + if (!userFields.containsKey(entry.key)) { + proFields[entry.key] = entry.value; + } + } + + if (userFields.isNotEmpty) { + final userResponse = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'), + headers: await _headers(), + body: jsonEncode(userFields), + ); + if (userResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'), + ); + } + } + + if (proFields.isNotEmpty) { + final proResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId', + ), + headers: await _headers(), + body: jsonEncode(proFields), + ); + if (proResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'), + ); + } + } + + return getAssistanteMaternelle(amUserId); + } + + static Future attachEnfantToAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return _amModelFromBody(response.body); + } + + static Future detachEnfantFromAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant')); + } + if (response.body.isEmpty) { + return getAssistanteMaternelle(amUserId); + } + return _amModelFromBody(response.body); + } + + static AssistanteMaternelleModel _amModelFromBody(String body) { + final decoded = jsonDecode(body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } // Récupérer la liste des administrateurs (via /users filtré ou autre) diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 432a782..cfc7826 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -102,55 +101,10 @@ class _AssistanteMaternelleManagementWidgetState void _openAssistanteDetails(AssistanteMaternelleModel assistante) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: assistante.user.fullName.isEmpty - ? 'Assistante maternelle' - : assistante.user.fullName, - subtitle: assistante.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(assistante.user.id)), - AdminDetailField( - label: 'Numero agrement', - value: _v(assistante.approvalNumber), - ), - AdminDetailField( - label: 'Ville residence', - value: _v(assistante.residenceCity), - ), - AdminDetailField( - label: 'Capacite max', - value: assistante.maxChildren?.toString() ?? '-', - ), - AdminDetailField( - label: 'Places disponibles', - value: assistante.placesAvailable?.toString() ?? '-', - ), - AdminDetailField( - label: 'Telephone', - value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–', - ), - AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), - AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), - AdminDetailField( - label: 'Code postal', - value: _v(assistante.user.codePostal), - ), - ], - onEdit: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Modifier a implementer')), - ); - }, - onDelete: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Supprimer a implementer')), - ); - }, + builder: (context) => AdminAmEditModal( + assistante: assistante, + onSaved: _loadAssistantes, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart new file mode 100644 index 0000000..55d6de9 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -0,0 +1,721 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. +class AdminAmEditModal extends StatefulWidget { + final AssistanteMaternelleModel assistante; + final VoidCallback? onSaved; + + const AdminAmEditModal({ + super.key, + required this.assistante, + this.onSaved, + }); + + @override + State createState() => _AdminAmEditModalState(); +} + +class _AdminAmEditModalState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabCtrl; + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _capaciteCtrl; + late final TextEditingController _placesCtrl; + + late String _statut; + late bool _disponible; + late List _children; + late final ScrollController _childrenScrollCtrl; + + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _proTabHeight = 300; + static const List _photoProRowLayout = [2, 2, 2]; + + double _tabViewHeight(int index) { + switch (index) { + case 1: + return _proTabHeight; + case 2: + return 280; + default: + return 292; + } + } + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 3, vsync: this); + final u = widget.assistante.user; + final am = widget.assistante; + + _nomCtrl = TextEditingController(text: u.nom ?? ''); + _prenomCtrl = TextEditingController(text: u.prenom ?? ''); + _emailCtrl = TextEditingController(text: u.email); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); + _adresseCtrl = TextEditingController(text: u.adresse ?? ''); + _villeCtrl = TextEditingController(text: u.ville ?? ''); + _cpCtrl = TextEditingController(text: u.codePostal ?? ''); + _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); + _capaciteCtrl = TextEditingController( + text: am.maxChildren?.toString() ?? '', + ); + _placesCtrl = TextEditingController( + text: am.placesAvailable?.toString() ?? '', + ); + _statut = u.statut ?? 'en_attente'; + _disponible = am.available ?? true; + _children = List.of(am.children); + _childrenScrollCtrl = ScrollController(); + + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _capaciteCtrl, + _placesCtrl, + ]) { + c.addListener(_markDirty); + } + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _tabCtrl.addListener(_onTabChanged); + _reloadChildren(); + } + + void _onTabChanged() { + if (!_tabCtrl.indexIsChanging) setState(() {}); + } + + void _onNameFieldChanged() => setState(() {}); + + @override + void dispose() { + _tabCtrl.dispose(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _capaciteCtrl, + _placesCtrl, + ]) { + c.dispose(); + } + _childrenScrollCtrl.dispose(); + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.assistante.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Assistante maternelle'; + } + return '$fn $ln'.trim(); + } + + String? _headerSubtitle() { + final parts = []; + final zone = (widget.assistante.residenceCity ?? '').trim(); + if (zone.isNotEmpty) parts.add('Zone : $zone'); + final agrement = _agrementCtrl.text.trim(); + if (agrement.isNotEmpty) parts.add('Agrément : $agrement'); + final dossier = widget.assistante.user.numeroDossier?.trim(); + if (dossier != null && dossier.isNotEmpty) { + parts.add('Dossier $dossier'); + } + if (parts.isEmpty) return null; + return parts.join(' · '); + } + + String _formatNirDisplay() { + final raw = widget.assistante.nir?.trim() ?? ''; + if (raw.isEmpty) return '–'; + final digits = nirToRaw(raw).toUpperCase(); + return digits.length == 15 ? formatNir(digits) : raw; + } + + int? _parseIntField(TextEditingController c) { + final t = c.text.trim(); + if (t.isEmpty) return null; + return int.tryParse(t); + } + + Future _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmer'), + content: const Text( + 'Enregistrer les modifications de la fiche assistante maternelle ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _saving = true); + try { + await UserService.updateAmFiche( + amUserId: widget.assistante.user.id, + body: { + 'nom': _nomCtrl.text.trim(), + 'prenom': _prenomCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': _villeCtrl.text.trim(), + 'code_postal': _cpCtrl.text.trim(), + 'statut': _statut, + 'approval_number': _agrementCtrl.text.trim(), + if (_parseIntField(_capaciteCtrl) != null) + 'max_children': _parseIntField(_capaciteCtrl), + if (_parseIntField(_placesCtrl) != null) + 'places_available': _parseIntField(_placesCtrl), + 'available': _disponible, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _reloadChildren() async { + try { + final refreshed = + await UserService.getAssistanteMaternelle(widget.assistante.user.id); + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + final kids = refreshed.children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + + if (!mounted) return; + setState(() => _children = kids); + } catch (_) {} + } + + Future _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _reloadChildren, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'enfant'), + content: Text( + 'Retirer ${child.fullName} de la fiche de cette assistante ?\n' + '(L\'enfant ne sera pas supprimé.)', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + ), + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + await UserService.detachEnfantFromAm( + amUserId: widget.assistante.user.id, + enfantId: child.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant détaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _attachChild() async { + List all; + try { + all = await UserService.getEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + final linkedIds = _children.map((c) => c.id).toSet(); + final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun enfant disponible à rattacher')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Rattacher un enfant'), + children: candidates + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e), + child: Text(e.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + try { + await UserService.attachEnfantToAm( + amUserId: widget.assistante.user.id, + enfantId: selected.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + Widget _identityTab() { + return SingleChildScrollView( + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ); + } + + Widget _proFieldsGrid() { + final u = widget.assistante.user; + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + compact: true, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationReadOnlyField( + value: _formatNirDisplay(), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationReadOnlyField( + value: formatIsoDateFr(u.dateNaissance), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationReadOnlyField( + value: _v(u.lieuNaissanceVille), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationReadOnlyField( + value: _v(u.lieuNaissancePays), + compact: true, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField( + controller: _agrementCtrl, + compact: true, + ), + ), + ValidationLabeledField( + label: 'Date d\'agrément', + field: ValidationReadOnlyField( + value: formatIsoDateFr(widget.assistante.agreementDate), + compact: true, + ), + ), + ], + ); + } + + Widget _proTab() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final bodyH = maxRowH; + final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: AdminAmPhotoFrame( + photoUrl: widget.assistante.user.photoUrl, + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _proFieldsGrid(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Disponible pour accueillir', + style: TextStyle(fontSize: 13), + ), + value: _disponible, + onChanged: (v) => setState(() { + _disponible = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _childrenCapacityFields() { + return ValidationEditableSection( + compact: true, + rowLayout: const [2], + fields: [ + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + compact: true, + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationEditableField( + controller: _placesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + compact: true, + ), + ), + ], + ); + } + + Widget _childrenTab() { + return LayoutBuilder( + builder: (context, constraints) { + const headerBlock = 88.0; + const capacityBlock = 56.0; + const gaps = 20.0; + final listHeight = (constraints.maxHeight - + headerBlock - + capacityBlock - + gaps) + .clamp(72.0, AdminChildrenAffiliationPanel.defaultViewportHeight); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _childrenCapacityFields(), + const SizedBox(height: 10), + Text( + 'Enfants accueillis : ${_children.length}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + 'Enfants rattachés à cette assistante (accueil / garde).', + style: TextStyle(fontSize: 12, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + Expanded( + child: AdminChildrenAffiliationPanel( + height: listHeight, + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, + emptyMessage: 'Aucun enfant rattaché à cette assistante', + ), + ), + ], + ); + }, + ); + } + + Widget _buildFooter() { + final isChildrenTab = _tabCtrl.index == 2; + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + if (isChildrenTab) + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + if (isChildrenTab) const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_headerSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _headerSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + TabBar( + controller: _tabCtrl, + onTap: (_) => setState(() {}), + labelColor: ValidationModalTheme.primaryActionBackground, + unselectedLabelColor: Colors.black54, + indicatorColor: ValidationModalTheme.primaryActionBackground, + tabs: const [ + Tab(text: 'Identité'), + Tab(text: 'Fiche professionnelle'), + Tab(text: 'Enfants accueillis'), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: SizedBox( + height: _tabViewHeight(_tabCtrl.index), + child: TabBarView( + controller: _tabCtrl, + children: [ + _identityTab(), + _proTab(), + _childrenTab(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: _buildFooter(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart new file mode 100644 index 0000000..9d53a7f --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +class AdminAmPhotoFrame extends StatelessWidget { + final String? photoUrl; + + static const double idPhotoAspectRatio = 35 / 45; + + const AdminAmPhotoFrame({super.key, this.photoUrl}); + + /// Largeur colonne photo pour remplir [height] (cadre inclus). + static double columnWidthForHeight(double height) { + const frame = 16.0; + final innerH = (height - frame).clamp(0.0, double.infinity); + return innerH * idPhotoAspectRatio + frame; + } + + @override + Widget build(BuildContext context) { + final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl); + return LayoutBuilder( + builder: (context, c) { + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = idPhotoAspectRatio; + + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(uniformFrame), + child: Align( + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: _photoContent(fullUrl, pw, ph), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _photoContent(String fullUrl, double pw, double ph) { + if (fullUrl.isEmpty) { + return ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + const SizedBox(height: 6), + Text( + 'Aucune photo', + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), + ], + ), + ); + } + return AuthNetworkImage( + url: fullUrl, + width: pw, + height: ph, + fit: BoxFit.cover, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart new file mode 100644 index 0000000..8a534bb --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; + +/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). +class AdminChildrenAffiliationPanel extends StatelessWidget { + final List children; + final ScrollController scrollController; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + final String emptyMessage; + final double? height; + + static const double _itemHeight = 58; + static const double defaultViewportHeight = _itemHeight * 2.5 + 8; + + const AdminChildrenAffiliationPanel({ + super.key, + required this.children, + required this.scrollController, + required this.onOpen, + required this.onDetach, + this.emptyMessage = 'Aucun enfant rattaché', + this.height, + }); + + @override + Widget build(BuildContext context) { + final panelHeight = height ?? defaultViewportHeight; + return Container( + height: panelHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: children.isEmpty + ? Center( + child: Text( + emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: _itemHeight, + itemCount: children.length, + itemBuilder: (_, i) { + final c = children[i]; + return AdminEnfantUserCard.fromSummary( + c, + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => onOpen(c), + ), + IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => onDetach(c), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index d4e63fd..f0f66ed 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -6,7 +6,8 @@ import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -42,13 +43,7 @@ class _AdminParentEditModalState extends State { bool _saving = false; bool _dirty = false; - static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; - static const double _modalWidth = 930; - /// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles. - static const double _childListItemHeight = 58; - static const double _childrenListViewportHeight = - _childListItemHeight * 2.5 + 8; @override void initState() { @@ -124,21 +119,6 @@ class _AdminParentEditModalState extends State { return 'Co-parent : $name'; } - String _displayStatus(String status) { - switch (status) { - case 'actif': - return 'Actif'; - case 'en_attente': - return 'En attente'; - case 'suspendu': - return 'Suspendu'; - case 'refuse': - return 'Refusé'; - default: - return status; - } - } - Future _save() async { if (!_dirty) return; final confirmed = await showDialog( @@ -337,39 +317,12 @@ class _AdminParentEditModalState extends State { } } - Widget _statusDropdown() { - return Container( - height: 34, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.black26), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _statuts.contains(_statut) ? _statut : _statuts.first, - isExpanded: true, - isDense: true, - style: const TextStyle(fontSize: 13, color: Colors.black87), - icon: const Icon(Icons.arrow_drop_down, size: 20), - items: _statuts - .map( - (s) => DropdownMenuItem( - value: s, - child: Text(_displayStatus(s)), - ), - ) - .toList(), - onChanged: (v) { - if (v == null) return; - setState(() { - _statut = v; - _dirty = true; - }); - }, - ), - ), + Widget _childrenPanel() { + return AdminChildrenAffiliationPanel( + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, ); } @@ -385,49 +338,6 @@ class _AdminParentEditModalState extends State { ); } - Widget _childrenPanel() { - return Container( - height: _childrenListViewportHeight, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - clipBehavior: Clip.antiAlias, - child: _children.isEmpty - ? const Center( - child: Text( - 'Aucun enfant rattaché', - style: TextStyle(fontSize: 14, color: Colors.black54), - ), - ) - : ListView.builder( - controller: _childrenScrollCtrl, - padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), - itemExtent: _childListItemHeight, - itemCount: _children.length, - itemBuilder: (_, i) { - final c = _children[i]; - return AdminEnfantUserCard.fromSummary( - c, - actions: [ - IconButton( - icon: const Icon(Icons.visibility_outlined), - tooltip: 'Voir / modifier', - onPressed: () => _openChild(c), - ), - IconButton( - tooltip: 'Détacher', - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - onPressed: () => _detachChild(c), - ), - ], - ); - }, - ), - ); - } - Widget _buildFooter() { return Row( children: [ @@ -465,10 +375,7 @@ class _AdminParentEditModalState extends State { return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: _modalWidth, - maxHeight: MediaQuery.of(context).size.height * 0.85, - ), + constraints: const BoxConstraints(maxWidth: _modalWidth), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -502,7 +409,16 @@ class _AdminParentEditModalState extends State { ), ), const SizedBox(width: 12), - SizedBox(width: 160, child: _statusDropdown()), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints( @@ -518,13 +434,13 @@ class _AdminParentEditModalState extends State { ), const Divider(height: 1), Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _identityFields(), - const SizedBox(height: 12), + const SizedBox(height: 10), Text( 'Nombre d\'enfants : ${_children.length}', style: const TextStyle( @@ -533,9 +449,9 @@ class _AdminParentEditModalState extends State { color: Colors.black87, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), _childrenPanel(), - const SizedBox(height: 16), + const SizedBox(height: 12), _buildFooter(), ], ), diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart new file mode 100644 index 0000000..a42b015 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). +class AdminStatusCapsule extends StatelessWidget { + final String statut; + final ValueChanged? onChanged; + + static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + const AdminStatusCapsule({ + super.key, + required this.statut, + this.onChanged, + }); + + static String displayStatus(String status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + case 'refuse': + return 'Refusé'; + default: + return status; + } + } + + @override + Widget build(BuildContext context) { + final value = statuts.contains(statut) ? statut : statuts.first; + return Container( + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 13, color: Colors.black87), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(displayStatus(s)), + ), + ) + .toList(), + onChanged: onChanged == null + ? null + : (v) { + if (v != null) onChanged!(v); + }, + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index ce1a3ba..24974c0 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -48,6 +48,7 @@ class ValidationFormGrid extends StatelessWidget { final List fields; final List? rowLayout; final Map>? rowFlex; + final bool compact; const ValidationFormGrid({ super.key, @@ -55,6 +56,7 @@ class ValidationFormGrid extends StatelessWidget { required this.fields, this.rowLayout, this.rowFlex, + this.compact = false, }); @override @@ -72,17 +74,17 @@ class ValidationFormGrid extends StatelessWidget { rowIndex++; if (count == 1) { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: rowFields.first, )); } else { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) const SizedBox(width: 16), + if (i > 0) SizedBox(width: compact ? 12 : 16), Expanded( flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] @@ -103,13 +105,13 @@ class ValidationFormGrid extends StatelessWidget { if (showTitle) ...[ Text( title!.trim(), - style: const TextStyle( - fontSize: 16, + style: TextStyle( + fontSize: compact ? 15 : 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), ], ...rows, ], @@ -121,13 +123,16 @@ class ValidationFormGrid extends StatelessWidget { class ValidationFieldDecoration { ValidationFieldDecoration._(); - static InputDecoration input({String? hint}) { + static InputDecoration input({String? hint, bool compact = false}) { return InputDecoration( isDense: true, filled: true, fillColor: Colors.grey.shade50, hintText: hint, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + contentPadding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 10, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade300), @@ -191,6 +196,7 @@ class ValidationEditableField extends StatelessWidget { final List? inputFormatters; final String? hintText; final int maxLines; + final bool compact; const ValidationEditableField({ super.key, @@ -199,17 +205,44 @@ class ValidationEditableField extends StatelessWidget { this.inputFormatters, this.hintText, this.maxLines = 1, + this.compact = false, }); + static const double _compactFieldHeight = 34; + @override Widget build(BuildContext context) { - return TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLines: maxLines, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: hintText), + if (!compact || maxLines > 1) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: TextStyle( + color: Colors.black87, + fontSize: compact ? 13 : 14, + ), + decoration: ValidationFieldDecoration.input( + hint: hintText, + compact: compact, + ), + ); + } + return SizedBox( + height: _compactFieldHeight, + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 13, height: 1.15), + decoration: ValidationFieldDecoration.input( + hint: hintText, + compact: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + ), + ), ); } } @@ -219,12 +252,14 @@ class ValidationEditableSection extends StatelessWidget { final List fields; final List? rowLayout; final Map>? rowFlex; + final bool compact; const ValidationEditableSection({ super.key, required this.fields, this.rowLayout, this.rowFlex, + this.compact = false, }); @override @@ -232,6 +267,7 @@ class ValidationEditableSection extends StatelessWidget { return ValidationFormGrid( rowLayout: rowLayout, rowFlex: rowFlex, + compact: compact, fields: fields, ); } @@ -241,22 +277,35 @@ class ValidationEditableSection extends StatelessWidget { class ValidationReadOnlyField extends StatelessWidget { final String value; final int? maxLines; + final bool compact; const ValidationReadOnlyField({ super.key, required this.value, this.maxLines = 1, + this.compact = false, }); + static const double _compactFieldHeight = 34; + @override Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + height: compact && maxLines == 1 ? _compactFieldHeight : null, + alignment: compact ? Alignment.centerLeft : null, + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 10, + ), decoration: ValidationFieldDecoration.container(), child: Text( value, - style: const TextStyle(color: Colors.black87, fontSize: 14), + style: TextStyle( + color: Colors.black87, + fontSize: compact ? 13 : 14, + height: compact ? 1.2 : null, + ), maxLines: maxLines, overflow: TextOverflow.ellipsis, ),