Squash depuis develop : détachement dernier parent autorisé, flag sans_responsable, vigilance liste Enfants, rattachement foyer depuis la fiche, polish détach et compteur foyer interim (#158 à suivre). Co-authored-by: Cursor <cursoragent@cursor.com>
892 lines
28 KiB
Dart
892 lines
28 KiB
Dart
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/parent_child_summary.dart';
|
||
import 'package:p_tits_pas/utils/am_vigilance.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_children_capacity_grid.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_select_enfant_modal.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 _nirCtrl;
|
||
late final TextEditingController _dateNaissanceCtrl;
|
||
late final TextEditingController _lieuNaissanceVilleCtrl;
|
||
late final TextEditingController _lieuNaissancePaysCtrl;
|
||
late final TextEditingController _dateAgrementCtrl;
|
||
late final TextEditingController _capaciteCtrl;
|
||
|
||
late String _statut;
|
||
late bool _disponible;
|
||
late int? _placesAvailable;
|
||
late List<ParentChildSummary> _children;
|
||
late Set<String> _baselineChildIds;
|
||
|
||
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
|
||
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
|
||
final Map<String, String> _transferFromAmIds = {};
|
||
|
||
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];
|
||
|
||
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
|
||
double _childrenTabHeight() {
|
||
const capacityFields = 72.0;
|
||
const titleSection = 40.0;
|
||
const inconsistencyExtra = 46.0;
|
||
var h = capacityFields +
|
||
titleSection +
|
||
AdminAmChildrenCapacityGrid.fixedHeight;
|
||
if (_placesInconsistent()) h += inconsistencyExtra;
|
||
return h + 4;
|
||
}
|
||
|
||
double _tabViewHeight(int index) {
|
||
switch (index) {
|
||
case 1:
|
||
return _proTabHeight;
|
||
case 2:
|
||
return _childrenTabHeight();
|
||
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 ?? '');
|
||
_nirCtrl = TextEditingController(text: _formatNirDisplay());
|
||
_dateNaissanceCtrl = TextEditingController(
|
||
text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''),
|
||
);
|
||
_lieuNaissanceVilleCtrl = TextEditingController(
|
||
text: u.lieuNaissanceVille ?? '',
|
||
);
|
||
_lieuNaissancePaysCtrl = TextEditingController(
|
||
text: u.lieuNaissancePays ?? '',
|
||
);
|
||
_dateAgrementCtrl = TextEditingController(
|
||
text: formatIsoDateFr(am.agreementDate, ifEmpty: ''),
|
||
);
|
||
_capaciteCtrl = TextEditingController(
|
||
text: am.maxChildren?.toString() ?? '',
|
||
);
|
||
_statut = u.statut ?? 'en_attente';
|
||
_disponible = am.available ?? true;
|
||
_placesAvailable = am.placesAvailable;
|
||
_children = List.of(am.children);
|
||
_baselineChildIds = _children.map((c) => c.id).toSet();
|
||
|
||
for (final c in [
|
||
_nomCtrl,
|
||
_prenomCtrl,
|
||
_emailCtrl,
|
||
_telCtrl,
|
||
_adresseCtrl,
|
||
_villeCtrl,
|
||
_cpCtrl,
|
||
_agrementCtrl,
|
||
_nirCtrl,
|
||
_dateNaissanceCtrl,
|
||
_lieuNaissanceVilleCtrl,
|
||
_lieuNaissancePaysCtrl,
|
||
_dateAgrementCtrl,
|
||
_capaciteCtrl,
|
||
]) {
|
||
c.addListener(_markDirty);
|
||
}
|
||
_capaciteCtrl.addListener(_onCapacityChanged);
|
||
_nomCtrl.addListener(_onNameFieldChanged);
|
||
_prenomCtrl.addListener(_onNameFieldChanged);
|
||
_tabCtrl.addListener(_onTabChanged);
|
||
_fetchChildrenFromServer();
|
||
}
|
||
|
||
bool _childrenChanged() {
|
||
final current = _children.map((c) => c.id).toSet();
|
||
return current.length != _baselineChildIds.length ||
|
||
!current.containsAll(_baselineChildIds);
|
||
}
|
||
|
||
void _syncPlacesAfterChildrenChange() {
|
||
final expected = _computedPlacesAvailable();
|
||
if (expected != null) _placesAvailable = expected;
|
||
}
|
||
|
||
void _onTabChanged() {
|
||
if (!_tabCtrl.indexIsChanging) setState(() {});
|
||
}
|
||
|
||
void _onNameFieldChanged() => setState(() {});
|
||
|
||
void _onCapacityChanged() => setState(() {});
|
||
|
||
@override
|
||
void dispose() {
|
||
_tabCtrl.dispose();
|
||
for (final c in [
|
||
_nomCtrl,
|
||
_prenomCtrl,
|
||
_emailCtrl,
|
||
_telCtrl,
|
||
_adresseCtrl,
|
||
_villeCtrl,
|
||
_cpCtrl,
|
||
_agrementCtrl,
|
||
_nirCtrl,
|
||
_dateNaissanceCtrl,
|
||
_lieuNaissanceVilleCtrl,
|
||
_lieuNaissancePaysCtrl,
|
||
_dateAgrementCtrl,
|
||
_capaciteCtrl,
|
||
]) {
|
||
c.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;
|
||
}
|
||
|
||
String? _frDateToIso(String text) => parseFrDateToIso(text);
|
||
|
||
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
||
|
||
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
|
||
bool get _capacityFull {
|
||
final max = _capaciteMax();
|
||
if (max == null) return false;
|
||
return _children.length >= max;
|
||
}
|
||
|
||
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
||
maxChildren: _capaciteMax(),
|
||
childrenCount: _children.length,
|
||
);
|
||
|
||
bool _placesInconsistent() => amHasPlacesInconsistency(
|
||
maxChildren: _capaciteMax(),
|
||
placesAvailable: _placesAvailable,
|
||
childrenCount: _children.length,
|
||
);
|
||
|
||
String _placesDisplayValue() {
|
||
if (_placesAvailable != null) return _placesAvailable.toString();
|
||
return '–';
|
||
}
|
||
|
||
void _applyPlacesCorrection() {
|
||
final expected = _computedPlacesAvailable();
|
||
if (expected == null) return;
|
||
setState(() {
|
||
_placesAvailable = expected;
|
||
_dirty = true;
|
||
});
|
||
}
|
||
|
||
String? _placesInconsistencyMessage() {
|
||
if (!_placesInconsistent()) return null;
|
||
final stored = _placesAvailable;
|
||
final expected = _computedPlacesAvailable();
|
||
final storedLabel = stored?.toString() ?? 'non renseigné';
|
||
final expectedLabel = expected?.toString() ?? '–';
|
||
return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), '
|
||
'le calcul (capacité ${_capaciteMax() ?? '–'} − ${_children.length} '
|
||
'enfant(s) rattaché(s)) donne $expectedLabel.';
|
||
}
|
||
|
||
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 {
|
||
if (_childrenChanged()) _syncPlacesAfterChildrenChange();
|
||
|
||
final currentIds = _children.map((c) => c.id).toSet();
|
||
for (final id in _baselineChildIds.difference(currentIds)) {
|
||
await UserService.detachEnfantFromAm(
|
||
amUserId: widget.assistante.user.id,
|
||
enfantId: id,
|
||
);
|
||
}
|
||
for (final id in currentIds.difference(_baselineChildIds)) {
|
||
// Transfert : détacher l'ancienne AM sans recalculer ses places
|
||
// (le ! de vigilance apparaît côté liste AM).
|
||
final previousAmId = _transferFromAmIds[id] ??
|
||
(await UserService.findAmForEnfant(id))?.user.id;
|
||
if (previousAmId != null &&
|
||
previousAmId != widget.assistante.user.id) {
|
||
await UserService.detachEnfantFromAm(
|
||
amUserId: previousAmId,
|
||
enfantId: id,
|
||
);
|
||
}
|
||
await UserService.attachEnfantToAm(
|
||
amUserId: widget.assistante.user.id,
|
||
enfantId: id,
|
||
);
|
||
}
|
||
|
||
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(),
|
||
'nir': nirToRaw(_nirCtrl.text),
|
||
if (_frDateToIso(_dateNaissanceCtrl.text) != null)
|
||
'date_naissance': _frDateToIso(_dateNaissanceCtrl.text),
|
||
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
|
||
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
|
||
if (_frDateToIso(_dateAgrementCtrl.text) != null)
|
||
'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
|
||
if (_capaciteMax() != null) 'max_children': _capaciteMax(),
|
||
if (_placesAvailable != null) 'places_available': _placesAvailable,
|
||
'available': _disponible,
|
||
},
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_dirty = false;
|
||
_saving = false;
|
||
_baselineChildIds = currentIds;
|
||
_transferFromAmIds.clear();
|
||
});
|
||
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> _fetchChildrenFromServer() 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;
|
||
_baselineChildIds = kids.map((c) => c.id).toSet();
|
||
_transferFromAmIds.clear();
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _refreshChildrenDetails() async {
|
||
try {
|
||
final all = await UserService.getEnfants();
|
||
final byId = {for (final e in all) e.id: e};
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_children = _children.map((c) {
|
||
final full = byId[c.id];
|
||
if (full != null) return ParentChildSummary.fromEnfant(full);
|
||
return c;
|
||
}).toList();
|
||
});
|
||
} 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: _refreshChildrenDetails,
|
||
),
|
||
);
|
||
} 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 ?',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
child: const Text('Détacher'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
|
||
setState(() {
|
||
_children = _children.where((c) => c.id != child.id).toList();
|
||
_transferFromAmIds.remove(child.id);
|
||
_syncPlacesAfterChildrenChange();
|
||
_dirty = true;
|
||
});
|
||
}
|
||
|
||
Future<void> _attachChild() async {
|
||
if (_capacityFull || !mounted) return;
|
||
final selected = await AdminSelectEnfantModal.show(
|
||
context,
|
||
excludeIds: _children.map((c) => c.id).toSet(),
|
||
title: 'Rattacher un enfant',
|
||
showSansGardeFilter: true,
|
||
);
|
||
if (selected == null || !mounted) return;
|
||
|
||
AssistanteMaternelleModel? previousAm;
|
||
try {
|
||
previousAm = await UserService.findAmForEnfant(selected.id);
|
||
} catch (_) {
|
||
previousAm = null;
|
||
}
|
||
if (!mounted) return;
|
||
|
||
final previousAmId = previousAm?.user.id;
|
||
final isTransfer = previousAmId != null &&
|
||
previousAmId != widget.assistante.user.id;
|
||
|
||
if (isTransfer) {
|
||
final amName = previousAm!.user.fullName.trim().isNotEmpty
|
||
? previousAm.user.fullName.trim()
|
||
: 'une autre assistante maternelle';
|
||
final gardeLabel =
|
||
(selected.gender ?? '').trim().toUpperCase() == 'F'
|
||
? 'déjà gardée'
|
||
: 'déjà gardé';
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Changer d\'affectation'),
|
||
content: Text(
|
||
'${selected.fullName} est $gardeLabel par $amName.\n\n'
|
||
'Confirmer le transfert vers cette assistante ? '
|
||
'L\'enfant sera détaché de $amName.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
child: const Text('Confirmer'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
}
|
||
|
||
setState(() {
|
||
_children = [
|
||
..._children,
|
||
ParentChildSummary.fromEnfant(selected),
|
||
];
|
||
if (isTransfer) {
|
||
_transferFromAmIds[selected.id] = previousAmId!;
|
||
}
|
||
_syncPlacesAfterChildrenChange();
|
||
_dirty = true;
|
||
});
|
||
}
|
||
|
||
|
||
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() {
|
||
return ValidationFormGrid(
|
||
title: 'Dossier professionnel',
|
||
rowLayout: _photoProRowLayout,
|
||
fields: [
|
||
ValidationLabeledField(
|
||
label: 'NIR',
|
||
field: ValidationEditableField(
|
||
controller: _nirCtrl,
|
||
hintText: '15 chiffres',
|
||
inputFormatters: [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
|
||
],
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'Date de naissance',
|
||
field: ValidationEditableField(
|
||
controller: _dateNaissanceCtrl,
|
||
hintText: 'jj/mm/aaaa',
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'Ville de naissance',
|
||
field: ValidationEditableField(
|
||
controller: _lieuNaissanceVilleCtrl,
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'Pays de naissance',
|
||
field: ValidationEditableField(
|
||
controller: _lieuNaissancePaysCtrl,
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'N° Agrément',
|
||
field: ValidationEditableField(
|
||
controller: _agrementCtrl,
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'Date d\'agrément',
|
||
field: ValidationEditableField(
|
||
controller: _dateAgrementCtrl,
|
||
hintText: 'jj/mm/aaaa',
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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() {
|
||
final inconsistent = _placesInconsistent();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
ValidationEditableSection(
|
||
rowLayout: const [2],
|
||
fields: [
|
||
ValidationLabeledField(
|
||
label: 'Capacité max (enfants)',
|
||
field: ValidationEditableField(
|
||
controller: _capaciteCtrl,
|
||
keyboardType: TextInputType.number,
|
||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||
),
|
||
),
|
||
ValidationLabeledField(
|
||
label: 'Places disponibles',
|
||
field: ValidationReadOnlyField(
|
||
value: _placesDisplayValue(),
|
||
error: inconsistent,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (inconsistent) ...[
|
||
const SizedBox(height: 6),
|
||
Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: 6,
|
||
runSpacing: 4,
|
||
children: [
|
||
Text(
|
||
_placesInconsistencyMessage()!,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Colors.red.shade700,
|
||
height: 1.3,
|
||
),
|
||
),
|
||
OutlinedButton(
|
||
onPressed: _saving ? null : _applyPlacesCorrection,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: Colors.red.shade800,
|
||
disabledForegroundColor: Colors.red.shade300,
|
||
side: BorderSide(color: Colors.red.shade400),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||
minimumSize: const Size(0, 28),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
visualDensity: VisualDensity.compact,
|
||
textStyle: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
child: const Text('Mettre à jour'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _childrenTab() {
|
||
final capacity = (_capaciteMax() ?? 4).clamp(1, 4);
|
||
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: 8),
|
||
AdminAmChildrenCapacityGrid(
|
||
children: _children,
|
||
capacity: capacity,
|
||
onOpen: _openChild,
|
||
onDetach: _detachChild,
|
||
onAttachEmpty: _capacityFull ? null : _attachChild,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildFooter() {
|
||
final isChildrenTab = _tabCtrl.index == 2;
|
||
final canAttachChild = !_saving && !_capacityFull;
|
||
return Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||
child: const Text('Fermer'),
|
||
),
|
||
const Spacer(),
|
||
if (isChildrenTab)
|
||
Tooltip(
|
||
message: _capacityFull
|
||
? 'Capacité maximale atteinte'
|
||
: 'Rattacher un enfant',
|
||
child: TextButton.icon(
|
||
onPressed: canAttachChild ? _attachChild : null,
|
||
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(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|