feat(#131): fiche AM éditable en 3 onglets et widgets admin partagés.
Modale admin AM (identité, dossier pro, enfants), API front avec repli, note back affiliation, et extraction gélule statut / liste enfants pour la fiche parent. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2fd97ddecb
commit
479a32b4bf
132
docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md
Normal file
132
docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md
Normal file
@ -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`
|
||||||
@ -1,30 +1,102 @@
|
|||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleModel {
|
class AssistanteMaternelleModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
final String? approvalNumber;
|
final String? approvalNumber;
|
||||||
|
final String? nir;
|
||||||
final String? residenceCity;
|
final String? residenceCity;
|
||||||
final int? maxChildren;
|
final int? maxChildren;
|
||||||
final int? placesAvailable;
|
final int? placesAvailable;
|
||||||
|
final String? biography;
|
||||||
|
final bool? available;
|
||||||
|
final String? agreementDate;
|
||||||
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
AssistanteMaternelleModel({
|
AssistanteMaternelleModel({
|
||||||
required this.user,
|
required this.user,
|
||||||
this.approvalNumber,
|
this.approvalNumber,
|
||||||
|
this.nir,
|
||||||
this.residenceCity,
|
this.residenceCity,
|
||||||
this.maxChildren,
|
this.maxChildren,
|
||||||
this.placesAvailable,
|
this.placesAvailable,
|
||||||
|
this.biography,
|
||||||
|
this.available,
|
||||||
|
this.agreementDate,
|
||||||
|
this.children = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
||||||
final userJson = json['user'] ?? json;
|
final root = Map<String, dynamic>.from(json);
|
||||||
|
final userJson = Map<String, dynamic>.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 user = AppUser.fromJson(userJson);
|
||||||
|
final children = _parseChildren(root);
|
||||||
|
|
||||||
return AssistanteMaternelleModel(
|
return AssistanteMaternelleModel(
|
||||||
user: user,
|
user: user,
|
||||||
approvalNumber: json['numero_agrement'] as String?,
|
approvalNumber: _str(
|
||||||
residenceCity: json['ville_residence'] as String?,
|
root['approval_number'] ?? root['numero_agrement'],
|
||||||
maxChildren: json['nb_max_enfants'] as int?,
|
),
|
||||||
placesAvailable: json['place_disponible'] as int?,
|
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<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
|
||||||
|
final children = <ParentChildSummary>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
|
||||||
|
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<String, dynamic>.from(link),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -513,7 +513,150 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
|
return data
|
||||||
|
.map((e) => AssistanteMaternelleModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(e as Map),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel> 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<String, dynamic>.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<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
|
||||||
|
static Future<AssistanteMaternelleModel> updateAmFiche({
|
||||||
|
required String amUserId,
|
||||||
|
required Map<String, dynamic> 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 = <String, dynamic>{};
|
||||||
|
for (final k in [
|
||||||
|
'nom',
|
||||||
|
'prenom',
|
||||||
|
'email',
|
||||||
|
'telephone',
|
||||||
|
'adresse',
|
||||||
|
'ville',
|
||||||
|
'code_postal',
|
||||||
|
'statut',
|
||||||
|
]) {
|
||||||
|
if (body.containsKey(k)) userFields[k] = body[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
final proFields = <String, dynamic>{};
|
||||||
|
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<AssistanteMaternelleModel> 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<AssistanteMaternelleModel> 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<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.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/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/admin_user_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
@ -102,55 +101,10 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminDetailModal(
|
builder: (context) => AdminAmEditModal(
|
||||||
title: assistante.user.fullName.isEmpty
|
assistante: assistante,
|
||||||
? 'Assistante maternelle'
|
onSaved: _loadAssistantes,
|
||||||
: 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')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
721
frontend/lib/widgets/admin/common/admin_am_edit_modal.dart
Normal file
721
frontend/lib/widgets/admin/common/admin_am_edit_modal.dart
Normal file
@ -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<AdminAmEditModal> createState() => _AdminAmEditModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||||
|
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<ParentChildSummary> _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<int> _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 = <String>[];
|
||||||
|
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<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _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<void> _openChild(ParentChildSummary child) async {
|
||||||
|
try {
|
||||||
|
final enfant = await UserService.getEnfant(child.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
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<void> _detachChild(ParentChildSummary child) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _attachChild() async {
|
||||||
|
List<EnfantAdminModel> 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<EnfantAdminModel>(
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
frontend/lib/widgets/admin/common/admin_am_photo_frame.dart
Normal file
112
frontend/lib/widgets/admin/common/admin_am_photo_frame.dart
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ParentChildSummary> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.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_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/common/identity_block.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
|
||||||
@ -42,13 +43,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
|
|
||||||
|
|
||||||
static const double _modalWidth = 930;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -124,21 +119,6 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
return 'Co-parent : $name';
|
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<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@ -337,39 +317,12 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _statusDropdown() {
|
Widget _childrenPanel() {
|
||||||
return Container(
|
return AdminChildrenAffiliationPanel(
|
||||||
height: 34,
|
children: _children,
|
||||||
decoration: BoxDecoration(
|
scrollController: _childrenScrollCtrl,
|
||||||
color: Colors.white,
|
onOpen: _openChild,
|
||||||
borderRadius: BorderRadius.circular(18),
|
onDetach: _detachChild,
|
||||||
border: Border.all(color: Colors.black26),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: DropdownButtonHideUnderline(
|
|
||||||
child: DropdownButton<String>(
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,49 +338,6 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
Widget _buildFooter() {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@ -465,10 +375,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
return Dialog(
|
return Dialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: const BoxConstraints(maxWidth: _modalWidth),
|
||||||
maxWidth: _modalWidth,
|
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -502,7 +409,16 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(width: 160, child: _statusDropdown()),
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: AdminStatusCapsule(
|
||||||
|
statut: _statut,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_statut = v;
|
||||||
|
_dirty = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
@ -518,13 +434,13 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_identityFields(),
|
_identityFields(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'Nombre d\'enfants : ${_children.length}',
|
'Nombre d\'enfants : ${_children.length}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@ -533,9 +449,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
_childrenPanel(),
|
_childrenPanel(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
_buildFooter(),
|
_buildFooter(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
66
frontend/lib/widgets/admin/common/admin_status_capsule.dart
Normal file
66
frontend/lib/widgets/admin/common/admin_status_capsule.dart
Normal file
@ -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<String>? 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<String>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,7 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
final List<ValidationLabeledField> fields;
|
final List<ValidationLabeledField> fields;
|
||||||
final List<int>? rowLayout;
|
final List<int>? rowLayout;
|
||||||
final Map<int, List<int>>? rowFlex;
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const ValidationFormGrid({
|
const ValidationFormGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@ -55,6 +56,7 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
required this.fields,
|
required this.fields,
|
||||||
this.rowLayout,
|
this.rowLayout,
|
||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -72,17 +74,17 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
rowIndex++;
|
rowIndex++;
|
||||||
if (count == 1) {
|
if (count == 1) {
|
||||||
rows.add(Padding(
|
rows.add(Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||||
child: rowFields.first,
|
child: rowFields.first,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
rows.add(Padding(
|
rows.add(Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < rowFields.length; i++) ...[
|
for (int i = 0; i < rowFields.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: 16),
|
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: (flexForRow != null && i < flexForRow.length)
|
flex: (flexForRow != null && i < flexForRow.length)
|
||||||
? flexForRow[i]
|
? flexForRow[i]
|
||||||
@ -103,13 +105,13 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
if (showTitle) ...[
|
if (showTitle) ...[
|
||||||
Text(
|
Text(
|
||||||
title!.trim(),
|
title!.trim(),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: compact ? 15 : 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: compact ? 8 : 12),
|
||||||
],
|
],
|
||||||
...rows,
|
...rows,
|
||||||
],
|
],
|
||||||
@ -121,13 +123,16 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
class ValidationFieldDecoration {
|
class ValidationFieldDecoration {
|
||||||
ValidationFieldDecoration._();
|
ValidationFieldDecoration._();
|
||||||
|
|
||||||
static InputDecoration input({String? hint}) {
|
static InputDecoration input({String? hint, bool compact = false}) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.grey.shade50,
|
fillColor: Colors.grey.shade50,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 10 : 12,
|
||||||
|
vertical: compact ? 7 : 10,
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
@ -191,6 +196,7 @@ class ValidationEditableField extends StatelessWidget {
|
|||||||
final List<TextInputFormatter>? inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
final String? hintText;
|
final String? hintText;
|
||||||
final int maxLines;
|
final int maxLines;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const ValidationEditableField({
|
const ValidationEditableField({
|
||||||
super.key,
|
super.key,
|
||||||
@ -199,17 +205,44 @@ class ValidationEditableField extends StatelessWidget {
|
|||||||
this.inputFormatters,
|
this.inputFormatters,
|
||||||
this.hintText,
|
this.hintText,
|
||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const double _compactFieldHeight = 34;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextField(
|
if (!compact || maxLines > 1) {
|
||||||
controller: controller,
|
return TextField(
|
||||||
keyboardType: keyboardType,
|
controller: controller,
|
||||||
inputFormatters: inputFormatters,
|
keyboardType: keyboardType,
|
||||||
maxLines: maxLines,
|
inputFormatters: inputFormatters,
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
maxLines: maxLines,
|
||||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
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<ValidationLabeledField> fields;
|
final List<ValidationLabeledField> fields;
|
||||||
final List<int>? rowLayout;
|
final List<int>? rowLayout;
|
||||||
final Map<int, List<int>>? rowFlex;
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const ValidationEditableSection({
|
const ValidationEditableSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.fields,
|
required this.fields,
|
||||||
this.rowLayout,
|
this.rowLayout,
|
||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -232,6 +267,7 @@ class ValidationEditableSection extends StatelessWidget {
|
|||||||
return ValidationFormGrid(
|
return ValidationFormGrid(
|
||||||
rowLayout: rowLayout,
|
rowLayout: rowLayout,
|
||||||
rowFlex: rowFlex,
|
rowFlex: rowFlex,
|
||||||
|
compact: compact,
|
||||||
fields: fields,
|
fields: fields,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -241,22 +277,35 @@ class ValidationEditableSection extends StatelessWidget {
|
|||||||
class ValidationReadOnlyField extends StatelessWidget {
|
class ValidationReadOnlyField extends StatelessWidget {
|
||||||
final String value;
|
final String value;
|
||||||
final int? maxLines;
|
final int? maxLines;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const ValidationReadOnlyField({
|
const ValidationReadOnlyField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.value,
|
required this.value,
|
||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const double _compactFieldHeight = 34;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
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(),
|
decoration: ValidationFieldDecoration.container(),
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
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,
|
maxLines: maxLines,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user