feat(#129): wizard admin création dossier famille (create/review).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -60,6 +60,8 @@ class ApiConfig {
|
|||||||
static const String userChildren = '/users/children';
|
static const String userChildren = '/users/children';
|
||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
|
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||||
|
static const String parentsDossier = '/parents/dossier';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||||
static const String assistantesMaternellesDossier =
|
static const String assistantesMaternellesDossier =
|
||||||
|
|||||||
@@ -645,6 +645,54 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Création dossier famille actif côté staff (#129).
|
||||||
|
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||||
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||||
|
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
|
||||||
|
static Future<Map<String, dynamic>> createParentDossier(
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _extractErrorMessage(
|
||||||
|
response.body,
|
||||||
|
'Erreur création dossier famille',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
/// Création dossier AM actif côté staff (#156).
|
/// Création dossier AM actif côté staff (#156).
|
||||||
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||||
|
|
||||||
|
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
|
||||||
|
class ParentDossierCreateModal extends StatefulWidget {
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onSuccess;
|
||||||
|
|
||||||
|
const ParentDossierCreateModal({
|
||||||
|
super.key,
|
||||||
|
required this.onClose,
|
||||||
|
this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentDossierCreateModal> createState() =>
|
||||||
|
_ParentDossierCreateModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
|
||||||
|
int? _stepIndex;
|
||||||
|
int? _stepTotal;
|
||||||
|
|
||||||
|
void _onStepChanged(int step, int total) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stepIndex = step;
|
||||||
|
_stepTotal = total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSuccess() {
|
||||||
|
widget.onSuccess?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const double _modalWidth = 930;
|
||||||
|
/// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]).
|
||||||
|
static double get _bodyHeight => ParentDossierWizard.shellBodyHeight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||||
|
final showStep =
|
||||||
|
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||||
|
child: Text(
|
||||||
|
'Nouveau dossier famille',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (showStep) ...[
|
||||||
|
Text(
|
||||||
|
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SizedBox(
|
||||||
|
height: _bodyHeight,
|
||||||
|
child: ParentDossierWizard.create(
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class UserManagementPanel extends StatefulWidget {
|
|||||||
class _UserManagementPanelState extends State<UserManagementPanel> {
|
class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||||
int _subIndex = 0;
|
int _subIndex = 0;
|
||||||
int _gestionnaireRefreshTick = 0;
|
int _gestionnaireRefreshTick = 0;
|
||||||
|
int _parentRefreshTick = 0;
|
||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
int _enfantRefreshTick = 0;
|
int _enfantRefreshTick = 0;
|
||||||
int _amRefreshTick = 0;
|
int _amRefreshTick = 0;
|
||||||
@@ -263,6 +265,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
switch (contentIndex) {
|
switch (contentIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
return ParentManagementWidget(
|
return ParentManagementWidget(
|
||||||
|
key: ValueKey('parents-$_parentRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
@@ -318,6 +321,24 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
|
||||||
|
if (contentIndex == 0) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return ParentDossierCreateModal(
|
||||||
|
onClose: () => Navigator.of(dialogContext).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _parentRefreshTick++);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (contentIndex == 1) {
|
if (contentIndex == 1) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
|
||||||
import 'package:p_tits_pas/services/api/api_config.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/common/auth_network_image.dart';
|
|
||||||
import 'validation_modal_theme.dart';
|
|
||||||
import 'validation_refus_form.dart';
|
|
||||||
import 'validation_valider_confirm_dialog.dart';
|
|
||||||
|
|
||||||
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
|
||||||
class ValidationFamilyWizard extends StatefulWidget {
|
class ValidationFamilyWizard extends StatelessWidget {
|
||||||
final DossierFamille dossier;
|
final DossierFamille dossier;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback onSuccess;
|
final VoidCallback onSuccess;
|
||||||
@@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget {
|
|||||||
this.onStepChanged,
|
this.onStepChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|
||||||
int _step = 0;
|
|
||||||
bool _showRefusForm = false;
|
|
||||||
bool _submitting = false;
|
|
||||||
final ScrollController _enfantsScrollController = ScrollController();
|
|
||||||
|
|
||||||
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
|
||||||
bool _enfantsIsScrollable = false;
|
|
||||||
bool _enfantsFadeLeft = false;
|
|
||||||
bool _enfantsFadeRight = false;
|
|
||||||
|
|
||||||
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
|
||||||
static const double _enfantsFadeExtent = 0.05;
|
|
||||||
|
|
||||||
int get _stepCount => 4;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
|
||||||
_enfantsScrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
|
||||||
|
|
||||||
void _syncEnfantsScrollFades() {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!_enfantsScrollController.hasClients) {
|
|
||||||
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
|
||||||
setState(() {
|
|
||||||
_enfantsIsScrollable = false;
|
|
||||||
_enfantsFadeLeft = false;
|
|
||||||
_enfantsFadeRight = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final p = _enfantsScrollController.position;
|
|
||||||
final scrollable = p.maxScrollExtent > 0;
|
|
||||||
final left = scrollable &&
|
|
||||||
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
|
||||||
final right = scrollable &&
|
|
||||||
p.pixels <
|
|
||||||
(p.maxScrollExtent -
|
|
||||||
(p.viewportDimension * _enfantsFadeExtent / 2));
|
|
||||||
if (scrollable != _enfantsIsScrollable ||
|
|
||||||
left != _enfantsFadeLeft ||
|
|
||||||
right != _enfantsFadeRight) {
|
|
||||||
setState(() {
|
|
||||||
_enfantsIsScrollable = scrollable;
|
|
||||||
_enfantsFadeLeft = left;
|
|
||||||
_enfantsFadeRight = right;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isEnAttente => widget.dossier.isEnAttente;
|
|
||||||
|
|
||||||
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
|
||||||
? widget.dossier.parents.first.id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
static String _v(String? s) =>
|
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
|
||||||
|
|
||||||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
|
||||||
static String _formatBirthDate(String? s) =>
|
|
||||||
formatIsoDateFr(s, ifEmpty: 'Non défini');
|
|
||||||
|
|
||||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_showRefusForm) {
|
return ParentDossierWizard.review(
|
||||||
return _buildRefusPage();
|
dossier: dossier,
|
||||||
}
|
onClose: onClose,
|
||||||
return Padding(
|
onSuccess: onSuccess,
|
||||||
padding: const EdgeInsets.all(20),
|
onStepChanged: onStepChanged,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Expanded(child: _buildStepContent()),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildNavigation(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStepContent() {
|
|
||||||
final d = widget.dossier;
|
|
||||||
switch (_step) {
|
|
||||||
case 0:
|
|
||||||
return IdentityBlock.readOnlyFromParentDossier(
|
|
||||||
d.parents.first,
|
|
||||||
title: 'Parent principal',
|
|
||||||
);
|
|
||||||
case 1:
|
|
||||||
return _buildParent2Step();
|
|
||||||
case 2:
|
|
||||||
return _buildEnfantsStep();
|
|
||||||
case 3:
|
|
||||||
return _buildPresentationStep();
|
|
||||||
default:
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildParent2Step() {
|
|
||||||
if (widget.dossier.parents.length < 2) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Text('Un seul parent pour ce dossier.',
|
|
||||||
style: TextStyle(color: Colors.black87)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return IdentityBlock.readOnlyFromParentDossier(
|
|
||||||
widget.dossier.parents[1],
|
|
||||||
title: 'Deuxième parent',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const double _idPhotoAspectRatio = 35 / 45;
|
|
||||||
|
|
||||||
Widget _buildEnfantsStep() {
|
|
||||||
final enfants = widget.dossier.enfants;
|
|
||||||
if (enfants.isEmpty) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Text('Aucun enfant renseigné.',
|
|
||||||
style: TextStyle(color: Colors.black87)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Enfants',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final cardHeight = constraints.maxHeight;
|
|
||||||
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
|
||||||
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
|
||||||
return NotificationListener<ScrollMetricsNotification>(
|
|
||||||
onNotification: (_) {
|
|
||||||
_syncEnfantsScrollFades();
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: ShaderMask(
|
|
||||||
blendMode: BlendMode.dstIn,
|
|
||||||
shaderCallback: (Rect bounds) {
|
|
||||||
final stops = <double>[
|
|
||||||
0.0,
|
|
||||||
_enfantsFadeExtent,
|
|
||||||
1.0 - _enfantsFadeExtent,
|
|
||||||
1.0,
|
|
||||||
];
|
|
||||||
if (!_enfantsIsScrollable) {
|
|
||||||
return LinearGradient(
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: const <Color>[
|
|
||||||
Colors.black,
|
|
||||||
Colors.black,
|
|
||||||
Colors.black,
|
|
||||||
Colors.black,
|
|
||||||
],
|
|
||||||
stops: stops,
|
|
||||||
).createShader(bounds);
|
|
||||||
}
|
|
||||||
final leftMask =
|
|
||||||
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
|
||||||
final rightMask =
|
|
||||||
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
|
||||||
return LinearGradient(
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: <Color>[
|
|
||||||
leftMask,
|
|
||||||
Colors.black,
|
|
||||||
Colors.black,
|
|
||||||
rightMask,
|
|
||||||
],
|
|
||||||
stops: stops,
|
|
||||||
).createShader(bounds);
|
|
||||||
},
|
|
||||||
child: Listener(
|
|
||||||
onPointerSignal: (event) {
|
|
||||||
if (event is PointerScrollEvent &&
|
|
||||||
_enfantsScrollController.hasClients) {
|
|
||||||
final offset = _enfantsScrollController.offset +
|
|
||||||
event.scrollDelta.dy;
|
|
||||||
_enfantsScrollController.jumpTo(offset.clamp(
|
|
||||||
_enfantsScrollController.position.minScrollExtent,
|
|
||||||
_enfantsScrollController.position.maxScrollExtent,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: _enfantsScrollController,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: enfants.length,
|
|
||||||
itemBuilder: (_, i) => Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
right: i < enfants.length - 1 ? 16 : 0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: cardWidth,
|
|
||||||
height: cardHeight,
|
|
||||||
child: _buildEnfantCard(enfants[i]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
|
||||||
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
|
||||||
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
|
||||||
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
|
||||||
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
|
||||||
|
|
||||||
static const double _enfantCardRadius = 12;
|
|
||||||
|
|
||||||
static List<BoxShadow> _enfantCardShadows() => [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.06),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
static BoxDecoration _enfantCardDecoration(String? gender) {
|
|
||||||
final g = (gender ?? '').trim().toUpperCase();
|
|
||||||
if (g == 'H') {
|
|
||||||
return BoxDecoration(
|
|
||||||
color: _enfantCardBoyBg,
|
|
||||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
|
||||||
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
|
||||||
boxShadow: _enfantCardShadows(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (g == 'F') {
|
|
||||||
return BoxDecoration(
|
|
||||||
color: _enfantCardGirlBg,
|
|
||||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
|
||||||
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
|
||||||
boxShadow: _enfantCardShadows(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
boxShadow: _enfantCardShadows(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
|
||||||
Widget _buildEnfantCard(EnfantDossier e) {
|
|
||||||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
|
||||||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
|
||||||
child: Container(
|
|
||||||
decoration: _enfantCardDecoration(e.gender),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, c) {
|
|
||||||
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
|
||||||
const padL = 12.0;
|
|
||||||
const padR = 8.0;
|
|
||||||
const padV = 8.0;
|
|
||||||
final maxW =
|
|
||||||
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
|
||||||
final maxH =
|
|
||||||
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
|
||||||
const ar = _idPhotoAspectRatio;
|
|
||||||
double ph = maxH;
|
|
||||||
double pw = ph * ar;
|
|
||||||
if (pw > maxW) {
|
|
||||||
pw = maxW;
|
|
||||||
ph = pw / ar;
|
|
||||||
}
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
|
||||||
child: columnStatusLabel == null
|
|
||||||
? SingleChildScrollView(
|
|
||||||
child: _buildEnfantInfoFields(e),
|
|
||||||
)
|
|
||||||
: CustomScrollView(
|
|
||||||
slivers: [
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _buildEnfantInfoFields(e),
|
|
||||||
),
|
|
||||||
SliverFillRemaining(
|
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
columnStatusLabel,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 14,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
|
|
||||||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
|
||||||
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
|
||||||
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
|
||||||
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
|
||||||
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
|
||||||
final dateValue = isANaitre
|
|
||||||
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
|
||||||
: _formatBirthDate(e.birthDate);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: _enfantLabeledField('Date de naissance', dateValue),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: _enfantLabeledField(
|
|
||||||
'Genre',
|
|
||||||
_genreEnfantLabel(e.gender, e.status),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _enfantLabeledField(String label, String value) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ValidationReadOnlyField(value: value),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
|
||||||
const photoRadius = 8.0;
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(photoRadius),
|
|
||||||
border: Border.all(color: Colors.black.withOpacity(0.08)),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: photoUrl.isEmpty
|
|
||||||
? ColoredBox(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: AuthNetworkImage(
|
|
||||||
url: photoUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _formatNom(String? lastName) {
|
|
||||||
final n = (lastName ?? '').trim().toUpperCase();
|
|
||||||
return n.isEmpty ? 'Non défini' : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
|
||||||
static String _genreEnfantLabel(String? gender, String? status) {
|
|
||||||
final g = (gender ?? '').trim().toUpperCase();
|
|
||||||
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
|
||||||
if (g == 'H') return 'Garçon';
|
|
||||||
if (g == 'F') return 'Fille';
|
|
||||||
if (isANaitre) return 'Non connu';
|
|
||||||
if (g.isEmpty) return 'Non défini';
|
|
||||||
return (gender ?? '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPresentationStep() {
|
|
||||||
final p = widget.dossier.presentation ?? '';
|
|
||||||
final text = p.trim().isEmpty ? 'Non défini' : p;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Présentation',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
text,
|
|
||||||
style:
|
|
||||||
const TextStyle(color: Colors.black87, fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNavigation() {
|
|
||||||
if (_step == 3) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step = 2);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (_isEnAttente && _firstParentId != null) ...[
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _submitting ? null : _refuser,
|
|
||||||
child: const Text('Refuser')),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: _submitting ? null : _onValiderPressed,
|
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
|
||||||
),
|
|
||||||
] else if (!_isEnAttente)
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: widget.onClose,
|
|
||||||
child: const Text('Fermer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
|
||||||
const Spacer(),
|
|
||||||
if (_step > 0) ...[
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step--);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step++);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Suivant'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onValiderPressed() async {
|
|
||||||
if (_submitting || _firstParentId == null) return;
|
|
||||||
final ok = await showValidationValiderConfirmDialog(
|
|
||||||
context,
|
|
||||||
body:
|
|
||||||
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
|
||||||
);
|
|
||||||
if (!mounted || !ok) return;
|
|
||||||
await _valider();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _valider() async {
|
|
||||||
if (_submitting || _firstParentId == null) return;
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.validerDossierFamille(_firstParentId!);
|
|
||||||
if (!mounted) return;
|
|
||||||
widget.onSuccess();
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e is Exception
|
|
||||||
? e.toString().replaceFirst('Exception: ', '')
|
|
||||||
: 'Erreur'),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _submitting = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refuser() => setState(() => _showRefusForm = true);
|
|
||||||
|
|
||||||
Widget _buildRefusPage() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ValidationRefusForm(
|
|
||||||
isSubmitting: _submitting,
|
|
||||||
onCancel: widget.onClose,
|
|
||||||
onPrevious: () => setState(() => _showRefusForm = false),
|
|
||||||
onSubmit: (comment) {
|
|
||||||
if (comment == null || comment.trim().isEmpty) return;
|
|
||||||
_refuserEnvoyer(comment.trim());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
|
|
||||||
Future<void> _refuserEnvoyer(String comment) async {
|
|
||||||
if (_submitting) return;
|
|
||||||
final parentId = _firstParentId?.trim();
|
|
||||||
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.refuseUser(parentId, comment: comment);
|
|
||||||
if (!mounted) return;
|
|
||||||
final nbEnAttente = widget.dossier.parents
|
|
||||||
.where((p) => p.statut == 'en_attente')
|
|
||||||
.length;
|
|
||||||
final msg = nbEnAttente > 1
|
|
||||||
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
|
|
||||||
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(msg),
|
|
||||||
duration: const Duration(seconds: 4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
widget.onSuccess();
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e is Exception
|
|
||||||
? e.toString().replaceFirst('Exception: ', '')
|
|
||||||
: 'Erreur'),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _submitting = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user